24 KiB
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-iconsmust be added to bothfrontend/package.jsonand rootpackage.jsondependencies, withnpm installrun in both locations (o2switch shared-hosting single-node_modulesconstraint — seeCLAUDE.md).- No backend/API changes. File size is derived entirely from the client-side
File.sizealready available at selection time. - No new automated frontend test harness — this project has none today; verification is manual (
npm run dev) plus plainnodesanity checks for pure functions. FileConfigCardandFileCardremain 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 fromfrontend/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 infrontend/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 intopackage.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 inmain.jsx. Verified against the publishedflag-icons@7.5.0package contents: it shipscss/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 thegbflag). -
Step 1: Install in
frontend/(local dev)
Run from the frontend/ directory:
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:
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.jsonfiles list it
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:
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
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
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 forfrontend/, so this is a manual verification run, not a committed test file)
Run from the repo root:
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
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-iconsCSS 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:
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:
.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
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:
formatBytesfrom Task 2 (../utils/formatBytes.jsrelative tofrontend/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 (FileCardreuses.file-tile-header/.file-tile-name/.file-tile-size). -
Step 1: Replace
.file-config-namewith the shared tile-header classes
In frontend/src/styles/home.css, replace this block (lines 62-65):
.file-config-name {
font-weight: 500;
flex: 1 1 100%;
}
with:
.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:
.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.jsxto use the new markup
Replace the full contents of frontend/src/components/FileConfigCard.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
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-sizefrom Task 4.formatBytesfrom Task 2 (./utils/formatBytes.jsrelative tofrontend/src/, sinceFileCard.jsxlives directly infrontend/src/, not incomponents/). -
Consumes: new
fileSizeprop, wired up by Task 6. -
Produces:
FileCard({ fileName, fileSize, jobId, initialError })— the addedfileSizeprop is optional (undefinedrenders "0 B" viaformatBytes), 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:
.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:
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
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 newfileSizeprop (Task 5). -
Consumes: verified backend contract —
POST /api/jobs(src/app.js:136-159) builds itsresultsarray with exactly one entry per uploaded file, in the same orderreq.fileswas received, for both success and failure cases (confirmed by reading the handler's single indexedforloop). SinceuploadFiles()(frontend/src/api.js:12-30) appends files toFormDatain the same order as itsitemsargument,uploadFiles(validItems)'s resolvedjobsarray is guaranteed index-aligned withvalidItems. -
Step 1: Attach the original file size to each returned job
In frontend/src/pages/HomePage.jsx, replace handleConvert (lines 80-85):
async function handleConvert() {
const validItems = pendingFiles.filter((item) => item.targetFormat);
const jobs = await uploadFiles(validItems);
setSubmittedJobs((current) => [...current, ...jobs]);
setPendingFiles([]);
}
with:
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):
<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:
<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/:
- Upload a file, confirm its size shows in the choose step (Task 4).
- 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.
- Once done, confirm a filled, styled download button appears with the same size still shown, and clicking it downloads the converted file.
- 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
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.:
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.