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

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-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:

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.json files 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 for frontend/, 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-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:

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: 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):

.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.jsx to 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-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:

.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 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):

  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/:

  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
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.