i18n.changeLanguage(lang) previously only ran in a client-only useEffect,
so every page's SSR pass (Header, Footer, HomePage hero text) always used
i18next's shared default language ('fr') regardless of the actual route —
meaning /en/ and every English conversion page served French body content
to crawlers, undermining the whole per-language SEO effort.
Fixed by cloning the i18next instance per page (i18n.cloneInstance({ lng })
via I18nextProvider) instead of mutating the shared singleton. Mutating the
shared instance directly during render would have been unsafe anyway: the
SSG build renders up to `concurrency` pages in parallel in the same
process, so one page's language change would leak into others rendering
at the same time. Verified across all 30 pairs x 2 languages that server-
rendered body content now matches the page's actual language, with no
cross-page leakage.
This likely also explains the intermittent React hydration error #418
reported in production: a client that hydrates against SSR content whose
language could shift depending on render-order timing is a hydration
mismatch waiting to happen.
useTheme's initial state now always renders 'light' on both server and
client's first render pass, matching exactly. The real stored/OS-preferred
theme is applied afterward in an effect (runs only post-hydration), instead
of computing it during the initializer, which returned different values
between the server (no window) and a client whose actual localStorage/
matchMedia preference differed from the SSR default (e.g. dark-mode OS or
a stored 'dark' preference) — a real hydration mismatch on the ThemeToggle's
rendered icon and aria-pressed attribute, intermittent per visitor/system.
Since the vite-react-ssg migration replaced the dynamic /:lang route param
with literal /fr and /en routes, useParams().lang always returned undefined
here, breaking the header brand link (linked to /undefined/) and silently
breaking the language switcher (always resolved otherLang to 'fr').
Fixes SeoHead (title/canonical/hreflang/JSON-LD) to render during SSR via
vite-react-ssg's Head component instead of a client-only useEffect, so
crawlers see real tags without executing JS. Also fixes an SSR crash in
useTheme (localStorage access with no window during server render) and
removes now-duplicated static meta tags from index.html.
Applies prisma/migrations/*/migration.sql on first container boot via a
sourced docker-entrypoint-initdb.d script, so a fresh container matches a
`prisma migrate deploy` run without needing shadow-database privileges.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Verified exact file-type v22 magic-byte detection for all 5 formats against
real fixture buffers (no normalizeFormat aliases needed), and simplified the
converter's resolution-scaling gate to match audio.js's validation-boundary
pattern (app.js range-checks, converter trusts the value).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every other family (images, documents, fonts, ebooks) had a showcase
pair in the marquee; archives was missing one after the archive
conversion feature landed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Also adds a --color-family-archives CSS variable (light + dark) that
the plan didn't call out explicitly but is needed for the new family
tile/icon to render with a color instead of falling back silently.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Also fixes a third double-extension bug the plan missed: app.js derived
each job's uuid via path.basename(file.filename, path.extname(...)),
which only strips the last dot segment. For a compound extension like
tar.gz this left "<uuid>.tar" as the "uuid", overflowing the uuid
column (Char(36)) and crashing the request. Replaced with
stripExtension(). Also bumped rateLimitMaxJobs in jobs.test.js's config
override — the file's cumulative POST /api/jobs calls across all tests
now exceeds the production default (20/window) once the archive tests
are included, causing spurious connection resets independent of any
real bug.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
archiver v8 (installed) uses a class-based API (`ZipArchive`) rather than
the factory-function API most examples online still describe — adjusted
createZip() to match after the first test run surfaced the mismatch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Lets users drop an uploaded file from the UI list before or after
conversion, without cancelling anything server-side.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rebuild the visual identity from the actual Ombrora mark (violet/cyan aurora
blobs) instead of the generic blue/amber scheme, add Bricolage Grotesque and
IBM Plex Mono for display and code-like text, and give the hero an animated
aurora backdrop, gradient headline, and a format-pair marquee driven by the
real registered converters.
Rework the file config form: target format and icon size are both chip
groups now instead of a native select, and quality/compression sliders get a
gradient-filled track, custom thumb, and a mono value pill to match. File
type icons are color-coded by family (images/documents/fonts/ebooks) and
reused across FileConfigCard, FileCard, and FormatsGrid.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The download button was showing the input file's size (carried over
client-side from upload) instead of the actual converted file's size.
It now reads outputSizeBytes from the polled job status once done.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>