feat(frontend): redesign homepage and file config form with aurora palette

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>
This commit is contained in:
2026-07-31 16:34:13 +02:00
co-authored by Claude Sonnet 5
parent 023a96e5d8
commit 987af80286
14 changed files with 770 additions and 206 deletions
+91 -56
View File
@@ -1,96 +1,131 @@
import { File as FileIcon } from '@phosphor-icons/react';
import { formatBytes } from '../utils/formatBytes.js';
import { fileTypeMeta } from '../utils/fileFamily.js';
const QUALITY_FORMATS = ['jpg', 'jpeg', 'webp', 'avif', 'tiff'];
const ICON_SIZES = [16, 32, 48, 256, 512];
const DEFAULT_ICON_SIZE = 256;
function extensionOf(fileName) {
return fileName.split('.').pop();
}
function rangeProgress(value, min, max) {
return `${((value - min) / (max - min)) * 100}%`;
}
function RangeField({ label, value, min, max, onChange }) {
return (
<div className="range-field">
<div className="range-field-header">
<span>{label}</span>
<span className="value-pill">{value}</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
style={{ '--range-progress': rangeProgress(value, min, max) }}
onChange={(event) => onChange(Number(event.target.value))}
/>
</div>
);
}
export function FileConfigCard({ item, index, t, onTargetFormatChange, onQualityChange, onIconSizeChange }) {
const { Icon, colorVar } = fileTypeMeta(extensionOf(item.file.name));
return (
<li className="file-config-card">
<div className="file-tile-header">
<FileIcon size={24} weight="regular" />
<span className="file-tile-icon" style={{ '--tile-color': `var(${colorVar})` }}>
<Icon size={20} weight="regular" />
</span>
<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)}
>
<div className="chip-field">
<span>{t('quality.targetFormat')}</span>
<div className="chip-group" role="group" aria-label={t('quality.targetFormat')}>
{item.targets.map((target) => (
<option key={target} value={target}>
{target}
</option>
<button
key={target}
type="button"
className={`chip${item.targetFormat === target ? ' chip-active' : ''}`}
aria-pressed={item.targetFormat === target}
onClick={() => onTargetFormatChange(index, target)}
>
{target.toUpperCase()}
</button>
))}
</select>
{item.targetFormat && <span className="format-badge">{item.targetFormat.toUpperCase()}</span>}
</div>
</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>
<RangeField
label={t('quality.label')}
value={item.quality}
min={1}
max={100}
onChange={(value) => onQualityChange(index, value)}
/>
)}
{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>
<RangeField
label={t('quality.compression')}
value={item.quality}
min={0}
max={9}
onChange={(value) => onQualityChange(index, value)}
/>
)}
{item.targetFormat === 'ico' && (
<label>
{t('quality.iconSize')}
<select
value={item.iconSize ?? DEFAULT_ICON_SIZE}
onChange={(event) => onIconSizeChange(index, Number(event.target.value))}
>
<div className="chip-field">
<span>{t('quality.iconSize')}</span>
<div className="chip-group" role="group" aria-label={t('quality.iconSize')}>
{ICON_SIZES.map((size) => (
<option key={size} value={size}>
<button
key={size}
type="button"
className={`chip${(item.iconSize ?? DEFAULT_ICON_SIZE) === size ? ' chip-active' : ''}`}
aria-pressed={(item.iconSize ?? DEFAULT_ICON_SIZE) === size}
onClick={() => onIconSizeChange(index, size)}
>
{size}px
</option>
</button>
))}
</select>
</label>
</div>
</div>
)}
{item.targetFormat === 'pdf' && (
<label>
<input
type="checkbox"
checked={item.quality !== null}
onChange={(event) => onQualityChange(index, event.target.checked ? 90 : null)}
/>
{t('quality.compressPdf')}
<div className="pdf-field">
<label className="switch-row">
<span className="switch">
<input
type="checkbox"
checked={item.quality !== null}
onChange={(event) => onQualityChange(index, event.target.checked ? 90 : null)}
/>
<span className="switch-track" aria-hidden="true" />
</span>
<span>{t('quality.compressPdf')}</span>
</label>
{item.quality !== null && (
<input
type="range"
min="1"
max="100"
<RangeField
label={t('quality.label')}
value={item.quality}
onChange={(event) => onQualityChange(index, Number(event.target.value))}
min={1}
max={100}
onChange={(value) => onQualityChange(index, value)}
/>
)}
</label>
</div>
)}
</div>
) : (