Files
convert/docs/superpowers/plans/2026-07-31-file-card-polish.md
T
2026-08-02 10:57:41 +02:00

657 lines
24 KiB
Markdown

# File Card / Language Switcher Polish Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Give the file-conversion flow (`frontend/`) flag-icon language switching, a restyled format/quality picker, a consistent icon+name+size header across the choose/convert/download states, and human-readable file sizes — all frontend-only, no backend changes.
**Architecture:** A new `formatBytes()` util plus a shared `.file-tile-header` CSS shape used by both `FileConfigCard` (choose step) and `FileCard` (convert/download step), which stay separate components. File size is read from the browser `File` object client-side and threaded through `HomePage` state into both components — no API change. The language switcher swaps its text label for a `flag-icons` icon.
**Tech Stack:** React 19, `react-i18next`, `@phosphor-icons/react` (already installed), `flag-icons` (new), plain CSS with the existing `--color-*` custom-property design tokens.
## Global Constraints
- `flag-icons` must be added to **both** `frontend/package.json` and root `package.json` dependencies, with `npm install` run in both locations (o2switch shared-hosting single-`node_modules` constraint — see `CLAUDE.md`).
- No backend/API changes. File size is derived entirely from the client-side `File.size` already available at selection time.
- No new automated frontend test harness — this project has none today; verification is manual (`npm run dev`) plus plain `node` sanity checks for pure functions.
- `FileConfigCard` and `FileCard` remain separate components (different responsibilities) — share CSS classes, not a merged component.
- The target-format picker stays a native `<select>` (not a custom chip grid).
- Use only the existing `var(--color-*)` design tokens from `frontend/src/index.css` — no new hardcoded colors.
- The spinner animation must respect `prefers-reduced-motion` — already globally enforced by the `@media (prefers-reduced-motion: reduce)` block in `frontend/src/index.css:68-72`, so no per-component handling is needed.
- Package versions: let `npm install <pkg>` resolve and write the version itself; do not hand-write a version number into `package.json`.
---
### Task 1: Add the `flag-icons` dependency
**Files:**
- Modify: `frontend/package.json`
- Modify: `package.json`
- Modify: `frontend/src/main.jsx`
**Interfaces:**
- Produces: global CSS classes `.fi` (base) and `.fi-fr` / `.fi-gb` (per-flag), available anywhere in the app once imported once in `main.jsx`. Verified against the published `flag-icons@7.5.0` package contents: it ships `css/flag-icons.min.css`, and that file defines `.fi-fr` / `.fi-gb` (not `.fi-en` — flag-icons keys are ISO 3166-1 country codes, so "English" maps to the `gb` flag).
- [ ] **Step 1: Install in `frontend/` (local dev)**
Run from the `frontend/` directory:
```bash
cd frontend && npm install flag-icons
```
Expected: `frontend/package.json` gains a `flag-icons` entry under `dependencies`, `frontend/package-lock.json` updates, and `frontend/node_modules/flag-icons/` is created.
- [ ] **Step 2: Install at the repo root (o2switch build)**
Run from the repo root:
```bash
npm install flag-icons
```
Expected: root `package.json` gains the same `flag-icons` entry under `dependencies`, root `package-lock.json` updates. This mirrors the existing pattern already used for `react-router-dom`, `react-i18next`, `i18next`, `@phosphor-icons/react` (see `CLAUDE.md`'s o2switch deployment notes) — packages `frontend/src/**` imports must also resolve from the root `node_modules` since the o2switch server never creates a `frontend/node_modules`.
- [ ] **Step 3: Verify both `package.json` files list it**
```bash
grep -n '"flag-icons"' package.json frontend/package.json
```
Expected: one matching line per file.
- [ ] **Step 4: Import the stylesheet once, globally**
Edit `frontend/src/main.jsx`:
```jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import 'flag-icons/css/flag-icons.min.css';
import './i18n.js';
import './index.css';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
```
- [ ] **Step 5: Commit**
```bash
git add package.json package-lock.json frontend/package.json frontend/package-lock.json frontend/src/main.jsx
git commit -m "feat(frontend): add flag-icons dependency"
```
---
### Task 2: `formatBytes` utility
**Files:**
- Create: `frontend/src/utils/formatBytes.js`
**Interfaces:**
- Produces: `formatBytes(bytes: number | null | undefined): string` — used by Task 4 (`FileConfigCard`) and Task 5 (`FileCard`).
- [ ] **Step 1: Write the implementation**
```js
const UNITS = ['B', 'KB', 'MB', 'GB'];
export function formatBytes(bytes) {
if (!bytes || bytes <= 0) return '0 B';
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1);
const value = bytes / 1024 ** exponent;
const rounded = Math.round(value * 10) / 10;
const formatted = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
return `${formatted} ${UNITS[exponent]}`;
}
```
- [ ] **Step 2: Sanity-check it with plain `node` (no test harness exists for `frontend/`, so this is a manual verification run, not a committed test file)**
Run from the repo root:
```bash
node --input-type=module -e "
import { formatBytes } from './frontend/src/utils/formatBytes.js';
const cases = [
[0, '0 B'],
[undefined, '0 B'],
[500, '500 B'],
[524288, '512 KB'],
[2516582, '2.4 MB'],
[1073741824, '1 GB'],
];
for (const [input, expected] of cases) {
const actual = formatBytes(input);
if (actual !== expected) throw new Error('formatBytes(' + input + ') = ' + actual + ', expected ' + expected);
}
console.log('all formatBytes cases passed');
"
```
Expected output: `all formatBytes cases passed`
- [ ] **Step 3: Commit**
```bash
git add frontend/src/utils/formatBytes.js
git commit -m "feat(frontend): add formatBytes util for human-readable file sizes"
```
---
### Task 3: Flag icons in the language switcher
**Files:**
- Modify: `frontend/src/components/LanguageSwitcher.jsx`
- Modify: `frontend/src/styles/layout.css:22-29`
**Interfaces:**
- Consumes: `flag-icons` CSS classes from Task 1 (`.fi`, `.fi-fr`, `.fi-gb`).
- Consumes: existing i18n keys `nav.switchToEnglish` / `nav.switchToFrench` (`frontend/src/locales/en.json:4-5`, `fr.json:4-5`) — unchanged, reused as the accessible name instead of visible text.
- [ ] **Step 1: Replace the text label with a flag icon**
Replace the full contents of `frontend/src/components/LanguageSwitcher.jsx`:
```jsx
import { Link, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export function LanguageSwitcher() {
const { lang } = useParams();
const { t } = useTranslation();
const otherLang = lang === 'fr' ? 'en' : 'fr';
const label = otherLang === 'en' ? t('nav.switchToEnglish') : t('nav.switchToFrench');
const flagCode = otherLang === 'en' ? 'gb' : 'fr';
return (
<Link to={`/${otherLang}/`} className="language-switcher" aria-label={label} title={label}>
<span className={`fi fi-${flagCode}`} aria-hidden="true" />
</Link>
);
}
```
- [ ] **Step 2: Size the flag and keep the 44px touch target**
In `frontend/src/styles/layout.css`, the existing `.language-switcher` rule (lines 22-29) already gives the link `min-height: 44px` and centers its content — that still applies to an icon child. Add a new rule directly after it:
```css
.language-switcher .fi {
font-size: 1.35rem;
border-radius: 3px;
box-shadow: 0 0 0 1px var(--color-border);
}
```
- [ ] **Step 3: Manual check**
Run `npm run dev` inside `frontend/`, open `/fr/` and `/en/`. Confirm: the FR page shows a GB flag (link target `/en/`), the EN page shows an FR flag (link target `/fr/`), and hovering/focusing shows the tooltip text from `title`. Confirm with a screen reader or the browser accessibility inspector that the link's accessible name is "English" / "Français" (not blank).
- [ ] **Step 4: Commit**
```bash
git add frontend/src/components/LanguageSwitcher.jsx frontend/src/styles/layout.css
git commit -m "feat(frontend): use flag icons in the language switcher"
```
---
### Task 4: Shared file-tile header + `FileConfigCard` restyle
**Files:**
- Modify: `frontend/src/styles/home.css`
- Modify: `frontend/src/components/FileConfigCard.jsx`
**Interfaces:**
- Consumes: `formatBytes` from Task 2 (`../utils/formatBytes.js` relative to `frontend/src/components/`).
- Produces: CSS classes `.file-tile-header`, `.file-tile-name`, `.file-tile-size`, `.format-select-group`, `.format-select`, `.format-badge` — consumed by Task 5 (`FileCard` reuses `.file-tile-header`/`.file-tile-name`/`.file-tile-size`).
- [ ] **Step 1: Replace `.file-config-name` with the shared tile-header classes**
In `frontend/src/styles/home.css`, replace this block (lines 62-65):
```css
.file-config-name {
font-weight: 500;
flex: 1 1 100%;
}
```
with:
```css
.file-tile-header {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 100%;
}
.file-tile-name {
font-weight: 500;
flex: 1 1 auto;
overflow-wrap: anywhere;
}
.file-tile-size {
font-size: 0.875rem;
color: var(--color-foreground);
opacity: 0.65;
white-space: nowrap;
}
```
- [ ] **Step 2: Add format-picker and control-spacing styles**
In `frontend/src/styles/home.css`, immediately after the `.file-config-controls` block (originally lines 67-72, now shifted by the edit above — locate it by its content, not the line number), add:
```css
.file-config-controls label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.format-select-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.format-select {
appearance: none;
background-color: var(--color-background);
color: var(--color-foreground);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.5rem 2rem 0.5rem 0.75rem;
font-weight: 500;
min-height: 44px;
background-image:
linear-gradient(45deg, transparent 50%, var(--color-foreground) 50%),
linear-gradient(135deg, var(--color-foreground) 50%, transparent 50%);
background-position:
calc(100% - 17px) calc(1.15em),
calc(100% - 12px) calc(1.15em);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.format-select:focus-visible {
border-color: var(--color-ring);
}
.format-badge {
background: var(--color-muted);
color: var(--color-primary);
border-radius: 999px;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.03em;
}
```
- [ ] **Step 3: Update `FileConfigCard.jsx` to use the new markup**
Replace the full contents of `frontend/src/components/FileConfigCard.jsx`:
```jsx
import { File as FileIcon } from '@phosphor-icons/react';
import { formatBytes } from '../utils/formatBytes.js';
const QUALITY_FORMATS = ['jpg', 'jpeg', 'webp', 'avif', 'tiff'];
const ICON_SIZES = [16, 32, 48, 256, 512];
const DEFAULT_ICON_SIZE = 256;
export function FileConfigCard({ item, index, t, onTargetFormatChange, onQualityChange, onIconSizeChange }) {
return (
<li className="file-config-card">
<div className="file-tile-header">
<FileIcon size={24} weight="regular" />
<span className="file-tile-name">{item.file.name}</span>
<span className="file-tile-size">{formatBytes(item.file.size)}</span>
</div>
{item.targets.length > 0 ? (
<div className="file-config-controls">
<div className="format-select-group">
<select
className="format-select"
value={item.targetFormat ?? ''}
onChange={(event) => onTargetFormatChange(index, event.target.value)}
>
{item.targets.map((target) => (
<option key={target} value={target}>
{target}
</option>
))}
</select>
{item.targetFormat && <span className="format-badge">{item.targetFormat.toUpperCase()}</span>}
</div>
{QUALITY_FORMATS.includes(item.targetFormat) && (
<label>
{t('quality.label', { value: item.quality })}
<input
type="range"
min="1"
max="100"
value={item.quality}
onChange={(event) => onQualityChange(index, Number(event.target.value))}
/>
</label>
)}
{item.targetFormat === 'png' && (
<label>
{t('quality.compression', { value: item.quality })}
<input
type="range"
min="0"
max="9"
value={item.quality}
onChange={(event) => onQualityChange(index, Number(event.target.value))}
/>
</label>
)}
{item.targetFormat === 'ico' && (
<label>
{t('quality.iconSize')}
<select
value={item.iconSize ?? DEFAULT_ICON_SIZE}
onChange={(event) => onIconSizeChange(index, Number(event.target.value))}
>
{ICON_SIZES.map((size) => (
<option key={size} value={size}>
{size}px
</option>
))}
</select>
</label>
)}
{item.targetFormat === 'pdf' && (
<label>
<input
type="checkbox"
checked={item.quality !== null}
onChange={(event) => onQualityChange(index, event.target.checked ? 90 : null)}
/>
{t('quality.compressPdf')}
{item.quality !== null && (
<input
type="range"
min="1"
max="100"
value={item.quality}
onChange={(event) => onQualityChange(index, Number(event.target.value))}
/>
)}
</label>
)}
</div>
) : (
<span className="error">{t('hero.unsupportedFormat')}</span>
)}
</li>
);
}
```
- [ ] **Step 4: Manual check**
Run `npm run dev` inside `frontend/`. Select an image file, confirm: the file icon, name, and a size like "1.2 MB" appear on one line; the format `<select>` shows the custom border/arrow and a badge with the selected extension (e.g. "WEBP") next to it; changing the quality slider still updates the displayed value; switching the target format to `ico` still shows the icon-size select, and to `pdf` still shows the compress checkbox — no control lost.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/styles/home.css frontend/src/components/FileConfigCard.jsx
git commit -m "feat(frontend): restyle format picker and add file size to the config card"
```
---
### Task 5: `FileCard` restyle (icon, size, spinner, download button)
**Files:**
- Modify: `frontend/src/styles/home.css`
- Modify: `frontend/src/FileCard.jsx`
**Interfaces:**
- Consumes: `.file-tile-header` / `.file-tile-name` / `.file-tile-size` from Task 4. `formatBytes` from Task 2 (`./utils/formatBytes.js` relative to `frontend/src/`, since `FileCard.jsx` lives directly in `frontend/src/`, not in `components/`).
- Consumes: new `fileSize` prop, wired up by Task 6.
- Produces: `FileCard({ fileName, fileSize, jobId, initialError })` — the added `fileSize` prop is optional (`undefined` renders "0 B" via `formatBytes`), so existing callers that don't pass it don't break; Task 6 updates the one real caller (`HomePage.jsx`) anyway.
- [ ] **Step 1: Add spinner and download-button CSS**
In `frontend/src/styles/home.css`, add after the `.convert-button` block:
```css
.job-status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-foreground);
opacity: 0.75;
}
.spin {
animation: spin 900ms linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.download-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: var(--color-accent);
color: var(--color-on-primary);
border: none;
border-radius: 8px;
padding: 0.6rem 1.25rem;
font-weight: 600;
min-height: 44px;
text-decoration: none;
}
.download-button:hover {
filter: brightness(1.05);
}
```
(No `prefers-reduced-motion` override is needed here — `frontend/src/index.css:68-72` already forces `animation: none !important` globally under that media query, which covers `.spin`.)
- [ ] **Step 2: Update `FileCard.jsx`**
Replace the full contents of `frontend/src/FileCard.jsx`:
```jsx
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { File as FileIcon, CircleNotch, DownloadSimple } from '@phosphor-icons/react';
import { fetchJobStatus, downloadUrl } from './api.js';
import { formatBytes } from './utils/formatBytes.js';
export function FileCard({ fileName, fileSize, jobId, initialError }) {
const { t } = useTranslation();
const [status, setStatus] = useState(initialError ? 'failed' : 'pending');
const [errorMessage, setErrorMessage] = useState(initialError ?? null);
useEffect(() => {
if (!jobId || initialError) return undefined;
let cancelled = false;
const interval = setInterval(async () => {
const job = await fetchJobStatus(jobId);
if (cancelled) return;
setStatus(job.status);
if (job.status === 'failed') setErrorMessage(job.errorMessage);
if (job.status === 'done' || job.status === 'failed') clearInterval(interval);
}, 1500);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [jobId, initialError]);
return (
<li className="file-card">
<div className="file-tile-header">
<FileIcon size={24} weight="regular" />
<span className="file-tile-name">{fileName}</span>
<span className="file-tile-size">{formatBytes(fileSize)}</span>
</div>
{(status === 'pending' || status === 'processing') && (
<span className="job-status">
<CircleNotch size={18} weight="bold" className="spin" />
{t('job.converting')}
</span>
)}
{status === 'done' && (
<a className="download-button" href={downloadUrl(jobId)}>
<DownloadSimple size={20} weight="regular" /> {t('job.download')}
</a>
)}
{status === 'failed' && <span className="error">{errorMessage}</span>}
</li>
);
}
```
- [ ] **Step 3: Commit**
```bash
git add frontend/src/styles/home.css frontend/src/FileCard.jsx
git commit -m "feat(frontend): add icon/size header, spinner, and styled download button to FileCard"
```
(Manual verification of this component happens in Task 6, once `HomePage` actually supplies `fileSize` and a real job to poll.)
---
### Task 6: Wire file size through `HomePage`
**Files:**
- Modify: `frontend/src/pages/HomePage.jsx:80-85` (`handleConvert`), `:116-124` (job-list render)
**Interfaces:**
- Consumes: `FileCard`'s new `fileSize` prop (Task 5).
- Consumes: verified backend contract — `POST /api/jobs` (`src/app.js:136-159`) builds its `results` array with exactly one entry per uploaded file, in the same order `req.files` was received, for both success and failure cases (confirmed by reading the handler's single indexed `for` loop). Since `uploadFiles()` (`frontend/src/api.js:12-30`) appends files to `FormData` in the same order as its `items` argument, `uploadFiles(validItems)`'s resolved `jobs` array is guaranteed index-aligned with `validItems`.
- [ ] **Step 1: Attach the original file size to each returned job**
In `frontend/src/pages/HomePage.jsx`, replace `handleConvert` (lines 80-85):
```js
async function handleConvert() {
const validItems = pendingFiles.filter((item) => item.targetFormat);
const jobs = await uploadFiles(validItems);
setSubmittedJobs((current) => [...current, ...jobs]);
setPendingFiles([]);
}
```
with:
```js
async function handleConvert() {
const validItems = pendingFiles.filter((item) => item.targetFormat);
const jobs = await uploadFiles(validItems);
const jobsWithSize = jobs.map((job, i) => ({ ...job, size: validItems[i].file.size }));
setSubmittedJobs((current) => [...current, ...jobsWithSize]);
setPendingFiles([]);
}
```
- [ ] **Step 2: Pass the size down to `FileCard`**
Replace the job-list render block (lines 116-124):
```jsx
<ul className="job-list">
{submittedJobs.map((job, index) =>
job.id ? (
<FileCard key={job.id} fileName={job.file} jobId={job.id} />
) : (
<FileCard key={`${job.file}-${index}`} fileName={job.file} initialError={job.error} />
)
)}
</ul>
```
with:
```jsx
<ul className="job-list">
{submittedJobs.map((job, index) =>
job.id ? (
<FileCard key={job.id} fileName={job.file} fileSize={job.size} jobId={job.id} />
) : (
<FileCard key={`${job.file}-${index}`} fileName={job.file} fileSize={job.size} initialError={job.error} />
)
)}
</ul>
```
- [ ] **Step 3: Manual end-to-end check**
This requires a running backend. Per `CLAUDE.md`, first check whether a `node src/server.js` / `node src/worker.js` pair is already running (`powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Select-Object ProcessId,CommandLine"`). If not, and you start your own for this check, only stop processes you started yourself afterward — never a pre-existing server/worker.
With the backend reachable and `npm run dev` running in `frontend/`:
1. Upload a file, confirm its size shows in the choose step (Task 4).
2. Click "Convertir"/"Convert". Confirm the download-step card shows the same file icon + name + size, with the spinning icon next to "Converting..." while the job is pending/processing.
3. Once done, confirm a filled, styled download button appears with the same size still shown, and clicking it downloads the converted file.
4. Upload an unsupported/mismatched file to trigger the error path; confirm the error message still renders (with icon + name + size still shown in the header).
- [ ] **Step 4: Commit**
```bash
git add frontend/src/pages/HomePage.jsx
git commit -m "feat(frontend): thread file size from upload through to the download card"
```
---
### Task 7: Cross-cutting manual verification pass
No new files — this is a checklist-only task confirming the spec's full "Testing" section, since no automated frontend suite exists to run it for you.
- [ ] **Step 1: Both languages**
`npm run dev` in `frontend/`. Visit `/fr/` and `/en/`. Confirm all new UI (flags, format badge, download button, spinner) renders correctly and every visible string is translated (no raw i18n keys, no English leaking onto the FR page or vice versa).
- [ ] **Step 2: Both themes**
Toggle dark mode. Confirm the flag's border (`box-shadow` against `--color-border`), the format badge, and the download button all keep sufficient contrast in dark mode (all colors come from existing `--color-*` tokens, which already have dark variants in `frontend/src/index.css:21-32`, so this should hold without extra CSS — verify visually).
- [ ] **Step 3: Responsive widths**
Resize the browser (or use devtools device toolbar) to roughly 375 / 768 / 1024 / 1440px. Confirm the `.file-tile-header` row (icon + name + size) wraps sensibly on narrow widths (name should be allowed to shrink/wrap via `flex: 1 1 auto` + `overflow-wrap: anywhere`; size stays on one line via `white-space: nowrap`) and the format-select/badge row doesn't overflow its card.
- [ ] **Step 4: Multi-file batch**
Upload 3+ files of different formats (e.g. one image, one document, one unsupported type) in one go. Confirm each config card and each resulting job card keeps its own correct name/size/format independent of the others (no index mixups) — this specifically exercises the index-based zip from Task 6.
- [ ] **Step 5: Final commit (if step 1-4 surfaced any fixups)**
If any of the above steps required a fix, commit it separately with a message describing what was wrong, e.g.:
```bash
git add <fixed files>
git commit -m "fix(frontend): <describe the specific issue found during manual verification>"
```
If no fixes were needed, no commit is required for this task.