Files
Oxicloud/docs/UIUX-ROADMAP.md
T
DioCrafts 81a93a489b feat: photo/video capture-date pipeline + premium UI/UX overhaul
Backend — Photos timeline now groups by real capture date instead of upload time. New MediaMetadataService (FileLifecycleHook) extracts EXIF DateTimeOriginal from images and container creation_time from videos (mov/mp4/mkv) via nom-exif, timezone-correct (OffsetTimeOriginal), persisting captured_at so the existing media_sort_date trigger takes over. Adds POST /admin/photos/metadata/reextract to backfill existing media. Falls back to upload date when no embedded date exists.

Frontend — premium grid cards: combined metadata line (relative date · size, owner avatar when shared), custom selection checkbox with a clear checked state, uniform full-width 4:3 thumbnail tiles independent of filename length, centered file-type icons, and a hit-test fix so checkbox/star/kebab clicks reach the controls (the decorative thumbnail no longer captures pointer events). Notification messages internationalised across all 16 locales. Broader polish: design tokens, a11y/focus-visible states, brand + PWA assets.

Chore — bump semver-compatible dependencies (cargo upgrade); add nom-exif 3.6.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:24:27 +02:00

424 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OxiCloud — Roadmap UI/UX: de 4.8 a 10
> Plan de trabajo derivado de la auditoría multi-agente del frontend (`/static`).
> Cada tarea está anclada a archivos reales. Las fases son secuenciales: **Fase 0
> (fundamentos) debe ir primero** porque casi todo lo demás depende de los tokens.
> Esfuerzo: **S** <2h · **M** ~medio día · **L** ~1-2 días · **XL** ~3 días+.
## Trayectoria de puntuación
| Fase | Qué entrega | Nota al terminar |
|------|-------------|:----------------:|
| — | Estado actual | **4.8** |
| **0 · Fundamentos** | Las 6 escalas de tokens que faltan (espaciado, tipo, radios, elevación, z-index, movimiento) + rampas de color OKLCH + higiene | **5.3** |
| **1 · Migración** | Aplicar las escalas en los ~55 archivos CSS, gobernar la paleta, unificar la marca en superficies externas, arreglar jerarquía de headings | **6.5** |
| **2 · Estados y A11y** | `:focus-visible`, disabled/loading, contraste WCAG AA, navegación por teclado, landmarks, reduced-motion, ARIA, touch targets | **8.0** |
| **3 · Pulido** | Empty states + skeletons, placeholders de fotos, lightbox completo, feel del movimiento, login atmosférico, reflow responsive, voz de marca | **9.2** |
| **4 · Clase mundial + guardarraíles** | Command palette, View Transitions, undo, i18n con Intl, offline, onboarding, microcopy + **CI (axe, contraste, visual regression) y design system documentado que sostienen el 10** | **10** |
---
## FASE 0 — Fundamentos (las escalas de tokens que faltan)
> El problema raíz de la auditoría: solo el color está sistematizado. Aquí se crean
> los ejes que faltan en [variables.css](../static/css/base/variables.css). 4-5 áreas
> propusieron estas mismas tareas de forma independiente → son la base correcta.
### Escalas de tokens ✅ (resuelto — definidas en [variables.css](../static/css/base/variables.css), [reset.css](../static/css/base/reset.css), [typography.css](../static/css/base/typography.css))
- [x] **f0-space** (S) — Escala de espaciado `--space-*` en grid de 4px (`--space-0`…`--space-24`, con medios pasos 2/6/10/14px). _Migración de los px crudos → Fase 1._
- [x] **f0-radius** (S) — Escala `--radius-*` (`xs`…`4xl`,`full`) y **token huérfano `--radius` ya definido** (alias de `--radius-2xl`; resuelve los `var(--radius,12px)` de share-public/device-verify).
- [x] **f0-type** (M) — Escala tipográfica en rem `--text-2xs`…`--text-6xl` con `--leading-*`/`--weight-*`/`--tracking-*`, `--font-sans`/`--font-mono`, `--measure-prose` (65ch) y `--icon-*` separada del texto.
- [x] **f0-type-base** (S) — `font-size: var(--text-base)` + `line-height: 1.5` en `body`; `html { font-size: 100% }` (honra zoom); clases `.heading-page/-section/-card/-eyebrow` + `.prose` en typography.css.
- [x] **f0-zindex** (S) — Capas semánticas `--z-*` (base…max) con huecos para insertar.
- [x] **f0-motion** (S) — `--motion-*` (instant…spinner) + `--ease-*` con **5 curvas `cubic-bezier`** (standard/emphasized de desaceleración por defecto) + `--spin-duration`.
- [x] **f0-elevation** (M) — Recetas `--shadow-xs…2xl` compuestas; **`--color-shadow-5xl` muerto eliminado** y **colisión dark arreglada** (base 0.3 < md 0.34 < lg 0.38).
- [~] **f0-breakpoints** (S) — Tokens `--bp-xs…xl` definidos como referencia para JS/docs. ⚠️ **Diferido (razón real, no "dependencia"):** lightningcss SÍ soporta `@custom-media`, pero `web/mod.rs` sirve el CSS **crudo desde `static/`** en debug (`cargo run`), sin preprocesar. `@custom-media` no tiene soporte nativo de navegador → `@media (--bp-sm)` rompería **todas** las media queries en dev. Solo viable si dev también preprocesa CSS (no lo hace). Las custom props tampoco funcionan en condiciones `@media` de forma nativa. Quedan como referencia.
- [x] **f0-density** (S) — Tokens `--density-*` (row/gap/control) + override `html[data-density="compact"]`. _Consumidos por filas/controles en Fase 1._
- [x] **f0-sidebar-fluid** (S) — `--sidebar-width: clamp(220px,18vw,280px)` + tokens min/max/collapsed. _Rail redimensionable → Fase 1._
- [x] **f0-dvh** (S) — `body { height:100vh; height:100dvh }` con fallback. _Migración de `100vh` en views → Fase 1 (f1-vh-views)._
### Fundamentos de color (curado, Opción B) ✅ — validado WCAG por script
- [~] **f0-oklch-ramps** (L) — **Adaptado:** curé los semánticos directamente a un hue sobrio cada uno con valores validados; **no** construí la capa formal de primitivas OKLCH (arquitectura opcional a futuro — el resultado de armonización ya se logró).
- [~] **f0-offwhites** (M) — **Diferido:** bajo valor y riesgo de aplanar el micro-layering de superficies sin poder renderizar. Revisar con la app en navegador.
- [x] **f0-brand-orange** (M) — Naranja confirmado como único acento de marca; `--color-accent-text`/`--color-on-accent` añadidos. _Degradar azul/índigo/púrpura → Fase 1 (f1-demote-*)._
- [x] **f0-textgray** (M) — 14 grises → 4 tiers AA-safe (secondary/muted/subtle/faint) + **todos los nombres legacy como alias** (0 roturas). muted/faint/placeholder **oscurecidos para pasar AA** (antes 2.3–4.0:1). _Adelanta f2-contrast-text._
- [x] **f0-semantic-success** (L) — Unificado a esmeralda sobrio (`#16a34a`); 6 verdes → alias canónicos.
- [x] **f0-semantic-warning** (L) — Unificado a ámbar (`text #b45309` AA, `border #f59e0b`); el oro brillante `#ffc107` se retira. 7 variantes → alias.
- [x] **f0-semantic-danger** (L) — Unificado a `#ef4444/#dc2626`; `danger-text-alt` ahora usa el rojo AA.
- [x] **f0-semantic-info** (L) — Unificado a azul (`text #1d4ed8` AA + tier dark); variantes → alias. _Calendar dots + iconos de nav regenerados a familia armonizada (S55/L62)._
### Tokens de acento, foco y marca
- [x] **f0-accent-text** (S) — `--color-accent-text: light-dark(#d23c18, #ff8a5c)` + `--color-focus-ring` añadidos. _Migración de enlaces → Fase 2 (f2-contrast-link)._
- [x] **f0-on-accent** (S) — `--color-on-accent: #ffffff` añadido. _Repunteo de consumidores de `--color-danger-text` → Fase 1._
- [~] **f0-brand-mark** (M) — **Parcial:** `--color-logo-gradient` canónico definido (unifica los dos gradientes divergentes). _SVG único + componente de lockup + wordmark/settings-layout tokens → Fase 1 (f1-logo-*, f1-settings-container)._
### Higiene y andamiaje (rápidas, habilitan el resto) ✅
- [x] **f0-stylelint-typo** (S) — Glob arreglado (`statc`→`static`). **0 hex/rgba crudos** fuera de variables/themes → CI no se romperá por colores. _(Auditoría 2026-06: una fase posterior había metido 3 hex en `base/a11y.css` (`@media prefers-contrast`) que stylelint habría marcado; movidos a `variables.css` (capa de tokens, whitelisted) → invariante restaurado y reverificado.)_
- [x] **f0-debug-red** (S) — Los tokens "debug" sí se usaban (badge de conteo de drag); **repunteados** a `--color-notification-badge`/`--color-danger-text` y eliminados de variables.css.
- [x] **f0-legacy-badge** (S) — Bloque FIXME (11 tokens `*-dark-*`) **eliminado** — confirmado 0 consumidores.
- [x] **f0-animations-file** (S) — [base/animations.css](../static/css/base/animations.css) creado con el `@keyframes spin` canónico, conectado temprano en main.css.
- [x] **f0-delete-spin-dupes** (S) — 6 `@keyframes spin` duplicados eliminados. _`oxi-spin`/`smdSpin` (1 uso c/u) diferidos a Fase 1 (requieren tocar consumidores)._
- [x] **f0-dead-upload-toast** (S) — Bloque muerto `.upload-toast` (162 líneas) + `<div>` oculto de index.html **eliminados** (0 referencias JS).
- [x] **f0-sr-only** (S) — `.sr-only` añadida en reset.css.
- [~] **f0-perf-baseline** (M) — **Diferida:** requiere correr la app + build + Lighthouse (no disponible en este entorno). El archivo de presupuestos se crea junto al primer baseline.
---
## FASE 1 — Migración (aplicar las escalas en todo el código)
### Migrar CSS a las escalas ✅ — codemod value-preserving (1645 reemplazos, 41 archivos, 0 cambio visual)
- [x] **f1-mig-base** (S) — forms.css migrado (reset.css ya estaba; main.css no tiene valores).
- [x] **f1-mig-layout** (M) — sidebar/topbar/content migrados.
- [x] **f1-mig-comp-core** (L) — buttons/fileManager/resourceList/fileType/breadcrumb migrados.
- [x] **f1-mig-comp-overlays** (L) — dialogs/modals/shareModal/groupsModal/shareDialog/contextMenu/uploadDropdown migrados.
- [x] **f1-mig-comp-misc** (L) — notifications/tooltip/itemTooltip/search/spinner/batchToolbar/chips/userVignette/userMenu/languageSelector/csp-utilities migrados.
- [x] **f1-mig-views** (XL) — los 14 views migrados (admin/music/profile/auth/photos/trash/recent/favorites/sharedWithMe/mySharesView/inlineViewer/device-verify/share-public/photosLightbox).
- [x] **f1-weight-normalize** (M) — `bold`/`normal` + numéricos → tokens `--weight-*`.
- [x] **f1-leading-normalize** (S) — `line-height` unitless exactos → tokens `--leading-*`.
- [~] **f1-snap-fractional** (M) — **Parcial:** los exactos ya van por token; quedan **184 px off-grid** (15/17/11/13px) sin token exacto → requieren decisión de snapping (cambio visual). _Pendiente._
- [ ] **f1-mig-root-dups** (M) — Reconciliar shims raíz vs views/ (separado de la migración de valores).
- [~] **f1-mig-motion-sweep** (L) — **Diferido:** las duraciones dominantes (0.15s/0.25s) no tienen token exacto → tokenizarlas es un cambio de *feel*, no value-preserving. Decisión aparte.
- [ ] **f1-spin-duration-token** (S) — Diferido (los spinners usan 0.7s/1s; unificar a `--spin-duration` cambia el valor).
- [ ] **f1-icon-size-scale** (M) — Diferido (separar `font-size` de icono vs texto no es automatizable sin ambigüedad).
- [ ] **f1-tracking-normalize** (M) — Diferido (los `letter-spacing` en px/em no tienen token exacto).
### Jerarquía de headings ✅ — cada página con un único h1, sin saltos
- [x] **f1-h-index** (S) — modal-title `h3→h2` (selector `.modal-header h2,h3` actualizado) → orden `h1→h2→h2`.
- [x] **f1-h-login** (S) — `<h1 class="sr-only">OxiCloud</h1>` añadido (usa la utilidad `.sr-only` de Fase 0); paneles quedan h2.
- [x] **f1-h-admin** (M) — `<h1 class="sr-only">Admin panel</h1>` añadido; secciones h2, subsecciones h3.
- [x] **f1-h-profile** (S) — panel de error `h2→h1` (queda h1 propio en cada panel excluyente).
- [x] **f1-h-rest** (S) — share/device-verify ya tenían h1 (verificado); nextcloud-login/success/error `h2→h1`.
### Gobernanza de paleta ✅ (parcial donde es decorativo)
- [~] **f1-fileicon-pairs** (M) — **Diferido:** re-derivar pares bg/text de iconos (pdf/doc/image/audio/video) de los semánticos — es pulido visual, revisar con la app.
- [—] **f1-fileicon-langs** (L) — **Descartado** por tu elección (mantener colores por lenguaje).
- [x] **f1-calendar-dots** (M) — Hecho en Fase 0 (familia armonizada S55/L62).
- [x] **f1-demote-blue** (M) — `--color-primary` aliasado a `--color-accent` → naranja único (flipa device-verify/share/userMenu).
- [x] **f1-demote-oidc** (S) — `--color-oidc-bg` → `--color-info-blue` (botón SSO distinto de la marca, en el azul info único).
- [x] **f1-demote-purple** (M) — Familia `--color-purple-*` **eliminada** (0 consumidores) de variables.css y del fallback de dark.css.
- [~] **f1-demote-misc-blue** (M) — **Parcial:** share-link → info-text. _admin-blue y avatar-gradient (decorativos) los dejé — bajo valor, requerirían restyle del admin._
- [x] **f1-named-colors** (S) — `white/black` → `#ffffff/#000000`; confirmado **0 named colors** fuera de variables/themes.
### Unificación de marca en superficies externas (primera impresión) ✅
- [x] **f1-share-accent** (S) — Hecho vía el alias `--color-primary → --color-accent` (wordmark + spinner de share ahora naranjas).
- [x] **f1-device-accent** (S) — Acento vía alias **+ bug del Deny arreglado** (`--color-error-text` como fondo → `--color-danger-bg`).
- [x] **f1-auth-foreground** (S) — 3× `--color-danger-text` foreground → `--color-on-accent` en auth.css (+ 2× en device-verify).
- [x] **f1-share-logo** (S) — Tile de marca canónico (`.brand-mark` con el SVG real) añadido a share.html.
- [x] **f1-device-logo** (S) — Tile `.brand-mark` añadido a device-verify.html (SVG inline → no hizo falta icons.js).
- [x] **f1-share-emoji-expired** (S) — 🚫 `&#128683;` → SVG alert-circle (color danger).
- [x] **f1-share-emoji-file** (M) — 📄 `&#128196;` → SVG de documento (color accent).
- [~] **f1-share-card-icons** (S) — **Diferido:** los cards de folder/file los construye publicShare.js (JS, no HTML).
- [x] **f1-share-download-icon** (S) — SVG de descarga añadido al botón Download.
- [~] **f1-login-nojs** (M) — **Diferido:** requiere entender la lógica de paneles de auth.js (riesgo de mostrar panel equivocado).
- [x] **f1-share-nojs** (S) — `<noscript>` con mensaje de fallback añadido.
### Marca: componente canónico
- [x] **f1-about-real-mark** (S) — **`fa-cloud` genérico → marca SVG real** en el modal "Acerca de" (+ gradiente unificado a `--color-logo-gradient`).
- [x] *(nuevo)* **brandLogo.css** — Componente `.brand-mark` canónico creado (gradiente único + SVG), usado en share + device + base para el resto.
- [~] **f1-logo-sidebar/admin/profile/auth** (M) — **Diferido:** sus tiles existentes funcionan y ya usan tokens de gradiente; migrarlos al componente es bajo valor / más riesgo sin render.
- [~] **f1-settings-container** (M) — **Diferido:** unificar admin 1080 vs profile 720 es una decisión de layout (los dashboards suelen ir más anchos).
- [~] **f1-extract-toggle** (M) — **Diferido:** refactor de organización (el toggle solo vive en admin).
### Responsive: reflow del file manager ✅ (overrides solo-móvil → desktop intacto)
- [x] **f1-list-mobile** (L) — **Reflow robusto basado en flex** en `@media (max-width:640px)`: cada fila → línea compacta (icono+nombre … tamaño+acciones), header de columnas oculto. Flex ignora los tracks del grid → sin desalineación header/fila.
- [x] **f1-list-trash-mobile** (M) — Cubierto por el reflow universal (oculta `.path-cell` en móvil).
- [x] **f1-list-fav-recent** (M) — Cubierto por el reflow universal (mismo `.file-item`).
- [x] **f1-grid-density** (M) — `--grid-card-min` tokenizado (200px → 140px en móvil) + gap apretado.
- [x] **f1-actions-bar-responsive** (M) — `flex-wrap` + `height:auto` en móvil.
- [x] **f1-gutter-align** (S) — Token `--gutter` (24px) en topbar **y** content → comparten borde izquierdo; baja a 16px en móvil vía override de `:root` en media query.
- [x] **f1-vh-views** (M) — `100vh → 100dvh` en 6 vistas (music/auth/admin/profile/device/share).
- [~] **f1-mobile-first-sidebar / topbar** (M) — **Diferido:** invertir a mobile-first es refactor con riesgo de regresión desktop, valor de usuario bajo (ya funcionan).
- [~] **f1-sidebar-bp-sync** (M) — Diferido (requiere revisar el supuesto 768px en JS).
- [~] **f1-mq-named** (L) — Diferido (mismo bloqueo que **f0-breakpoints**: `@custom-media` no es nativo y dev sirve CSS crudo sin preprocesar → rompería las media queries en `cargo run`).
- [~] **f1-container-query-list** (L) — Diferido (el reflow flex ya logra el objetivo móvil; container query es mejora opcional).
- [~] **f1-sidebar-resizable** (L) — Diferido (feature: JS + localStorage).
- [~] **f1-density-toggle** (M) — Diferido (feature: UI + wiring de los tokens `--density-*` ya definidos).
### Rendimiento (build)
> **Corrección (2026-06):** SÍ existe un build pipeline en Rust — [`build.rs`](../build.rs) (release) copia `static/→static-dist/`, bundlea CSS/JS a `app.{hash}.css|js` (content-hash FNV-1a), minifica con **lightningcss** (CSS) y **oxc** (JS), reescribe `index.html` y actualiza `sw.js`. **Sin npm/devDependencies** — todo son crates Rust ya en `Cargo.toml`. Esto desbloquea trabajo antes diferido "por no haber build step", pero cada tarea tiene su propio bloqueo real (abajo), distinto de la falsa premisa anterior.
- [x] **f1-resource-hints** (M) — **Hecho (build.rs):** `rewrite_index_html` inyecta `<link rel="preload" as="style">` + `<link rel="modulepreload">` para los bundles hasheados, justo tras `<meta charset>` (charset queda primero). El `theme-init.js` clásico es render-blocking y retrasa el descubrimiento de los bundles; los hints arrancan ambas descargas en paralelo, ahorrando un RTT serializado. _Verificado: harness `rustc` aislado sobre el `index.html` real → 1 stylesheet + hints presentes; build release regenera `static-dist/index.html`._
- [x] **f1-inline-theme-init** (S) — **Hecho (build.rs):** `theme-init.js` (clásico, render-blocking, ~200 B) ahora se **inlinea minificado** en el `<head>` de index.html durante `rewrite_index_html` → una petición menos en el critical path en la primera carga (antes de tener SW). Debe correr antes del paint (evita flash de tema) y se mantiene en su posición. Las otras 4 páginas (login/admin/profile/share) conservan la referencia externa (archivo cacheado). Fallback al `<script src>` si la lectura falla. _Verificado en `static-dist/index.html`: 0 `<script src=…theme-init>`, 1 script inline; archivo externo intacto._
- [~] **f1-critical-css** (L) — **Diferido (razón real, no "build step"):** inline crítico exige extraer el subconjunto above-the-fold (necesita render headless / penthouse) y cargar el resto async sin FOUC → **requiere render-review**. Inlinear el bundle entero infla el HTML (que va por `include_str!` al binario) y mata el cache de CSS. No es seguro a ciegas.
- [~] **f1-code-split** (XL) — **Diferido (razón real):** el bundler de `build.rs` es un **concatenador del grafo completo** (un IIFE, elimina `import`/`export`). Code-splitting de verdad necesita emisión de chunks + dedup de deps compartidas en `build.rs` **y** refactor de los entry points (photos/music) a `import()` dinámico. **admin/profile ya están fuera del bundle** (páginas aparte, minificadas sueltas por `minify_tree_js`). Cambio grande y arriesgado, ROI bajo en una SPA de un solo shell.
- [x] **f1-thumb-content-type** (S) — **Hecho (backend Rust):** `thumbnail_content_type()` en [mime_detect.rs](../src/common/mime_detect.rs) (reusa `infer`, 0 deps nuevas) detecta el formato real por magic bytes; los 3 `image/jpeg` hardcodeados en [file_handler.rs](../src/interfaces/api/handlers/file_handler.rs#L386) → Content-Type correcto. El fast-path (PNG/GIF/WebP guardado tal cual) ya no se sirve como JPEG. _Verificado: fmt + clippy -D warnings + 15 tests._
---
## FASE 2 — Estados e accesibilidad (el salto a "profesional")
### Foco, disabled, loading ✅ — baseline global + matriz de `.btn`
- [x] **f2-focus-token** (S) — **[base/a11y.css](../static/css/base/a11y.css)**: `:focus-visible { outline: 2px solid var(--color-focus-ring); offset 2px }` global + `:focus:not(:focus-visible){outline:none}` (suprime el anillo en click, lo muestra con teclado).
- [x] **f2-focus-rollout** (L) — El baseline da anillo de teclado a **todo** elemento sin override propio (botones, nav, links, context-menu, chips…). Mouse users: 0 cambio; teclado: foco visible en toda la app.
- [x] **f2-remove-outline-important** (S) — Los 2 `outline:none !important` del languageSelector eliminados → el toggle de idioma recupera anillo de teclado.
- [x] **f2-btn-focus** (S) — `.btn:focus-visible` explícito (anillo crujiente sobre los gradientes).
- [x] **f2-btn-disabled** (M) — `.btn:disabled / [disabled] / .is-disabled`: dimmed, sin lift, `pointer-events:none`.
- [x] **f2-btn-loading** (M) — `.btn.is-loading / [aria-busy]`: oculta el label + spinner inline (`@keyframes spin` canónico + `--spin-duration`), bloquea interacción.
- [x] **f2-audit-outline-none** (L) — **Auditado regla por regla:** son **8 reglas** (no 24 — el conteo previo contaba ocurrencias e ignoraba el anillo), **todas en inputs de texto** y **todas con indicador visible** → **0 peladas/rotas**. 7 ya usan el patrón correcto (`outline:none` + anillo acento `box-shadow`), que para inputs **debe** mostrarse en `:focus` (ratón+teclado) — convertirlos a `:focus-visible` sería peor UX. La única inconsistente (`.music-shares-input`: borde-only a `--color-text`) **armonizada** al anillo acento estándar. _Verificado: barrido confirma 0 peladas; build 0 errores._
- [~] **f2-states-components** (L) — **Parcial:** el foco de todos los componentes ya lo cubre el baseline. Falta disabled/loading por-componente más allá de `.btn`.
### Contraste WCAG ✅ — audit completo: 0 fallos en light Y dark (verificado por script)
- [x] **f2-contrast-text** (M) — muted/subtle/faint light → `#5e6a78` (pasan AA sobre `#fff`, page `#f5f7fa` **y** muted `#f0f3f7`). _El audit pilló que page/muted bg fallaban (4.43/4.28) — corregido._
- [x] **f2-contrast-dark** (M) — muted/subtle/faint dark → `#9fadbe` (pasan AA sobre surface, page **y el hover `#334155`**, que fallaba a 4.04/3.50).
- [x] **f2-contrast-link** (M) — auth-toggle-link, about-link, language-option activa → `--color-accent-text`; share-link ya iba a info (Fase 1). _Iconos y estados hover/activos se dejan en accent vibrante (intencional)._
- [x] **f2-contrast-matrix** (L) — **Audit exhaustivo** (resuelve `light-dark()` + alias): cada par text×bg + semántico en ambos modos. accent-text light → `#cc3a16` (pasaba 4.45 sobre page → ahora ≥4.5). **Resultado: 0 fallos.**
### Teclado, landmarks y semántica ✅ — la nav ya es operable por teclado
- [x] **f2-nav-anchors** (M) — Los 8 `<div class=nav-item>` → **`<button class=nav-item type=button>`** (teclado nativo Enter/Space, 0 cambio en el JS de click) + resets de botón en `.nav-item` + iconos `aria-hidden`.
- [x] **f2-nav-aria-current** (S) — `aria-current="page"` en el item activo (en `setCurrentSection`, [navigation.js](../static/js/app/navigation.js)).
- [x] **f2-landmarks-index** (S) — `.nav-menu` → `<nav aria-label="Primary">`; `.content-area` → `role="main" id="main"`. _(`<header>` del topbar: opcional, omitido.)_
- [x] **f2-skip-link** (S) — `<a href="#main" class="skip-link">` como primer elemento del body + estilo en a11y.css (oculto hasta foco).
- [x] **f2-lang** (S) — `lang="en"` en `<html>` de index.html y login.html (fallback estático; languageSelector.js lo actualiza en runtime).
- [x] **f2-landmarks-login** (S) — `.auth-container` → `role="main"` (+ el h1 sr-only de Fase 1).
- [x] **f2-landmarks-secondary** (M) — `role="main"` en admin/profile/share/device-verify; nextcloud-* ya tenían `lang`.
- [~] **f2-roving-tabindex** (L) — **Diferido:** navegación por flechas en grid/lista es JS no trivial (gestión de tabindex móvil).
### Modales, live regions, formularios — núcleo de modales hecho
- [x] **f2-modal-semantics** (M) — `role="dialog"` + `aria-modal="true"` + `aria-labelledby` en input-modal y about-modal (id en su h2).
- [x] **f2-modal-focus-trap** (M) — **Focus-trap (Tab cicla dentro) + guardado/restauración de foco** al trigger en la clase Modal ([modal.js](../static/js/components/modal.js)). _Verificado con `node --check`._
- [~] **f2-modal-escape** (M) — input-modal ya tenía ESC + click-overlay (confirmado). _Los otros overlays (share/groups/lightbox) son separados → follow-up._
- [x] **f2-icon-button-names** (M) — `aria-label` + `aria-hidden` en los botones de icono sin nombre (notif-bell, user-avatar, modal-close); el resto ya tenía label.
- [x] **f2-theme-radio** (S) — El radiogroup ya mantiene `aria-checked` (userMenu.js:104); arrow-roving = polish opcional.
- [x] **f2-live-region** (M) — **Hecho:** [liveRegion.js](../static/js/core/liveRegion.js) crea una región `aria-live` global (polite + assertive, `.sr-only`, lazy); [uiNotifications.js](../static/js/app/uiNotifications.js) anuncia cada notificación (errores en `assertive`). El toast de errorBoundary ya tenía `role="alert"`. _`node --check` OK; bundle release verificado._
- [~] **f2-form-errors** (M) — **Diferido:** `aria-invalid`/`role=alert` + foco al primer inválido en auth.js.
- [~] **f2-touch-targets** (M) — **Diferido:** subir iconos a 44px es cambio visual de layout — mejor con la app delante.
- [~] **f2-lang-picker-keyboard** (M) — **Diferido:** combobox custom, JS no trivial.
### Movimiento y preferencias del sistema ✅ — media queries CSS (aditivas, riesgo cero)
- [x] **f2-reduced-motion** (S) — **Guard global `prefers-reduced-motion`** en a11y.css (animaciones/transiciones a ~0, sin scroll suave). Era 0 ocurrencias → ahora honra la preferencia del SO en toda la app.
- [x] **f2-prefers-contrast** (M) — Tier `prefers-contrast: more`: bordes más fuertes, muted/subtle/faint → secondary, anillo de foco 3px.
- [x] **f2-forced-colors** (S) — `forced-colors: active`: anillo de foco usa `Highlight` del sistema.
### Skeletons y breadcrumb (loading sin salto)
- [x] **f2-skeleton-component** (M) — **[skeleton.css](../static/css/components/skeleton.css) creado:** primitivo `.skeleton` reutilizable + `.skeleton-line/-row/-tile` (la row hereda las columnas de la lista). Honra reduced-motion.
- [x] **f2-files-skeleton** (M) — **Cableado:** [filesView.js](../static/js/app/filesView.js) reemplaza el spinner de carga por **skeleton cards (grid) / rows (list)** según `app.currentView` (8 placeholders, `role=status`/`aria-busy`, honra reduced-motion). Nuevos `.skeleton-card` (tile 4:3 + 2 líneas) y `.skeleton-icon` en [skeleton.css](../static/css/components/skeleton.css).
- [~] **f2-list-views-skeleton / f2-photos-skeleton / f2-search-skeleton** (S) — **Pendientes:** mismo patrón ya disponible; falta cablearlo en photos/search/otras vistas (la de archivos ya sirve de plantilla).
- [~] **f2-breadcrumb-collapse** (L) — **Diferido:** colapso con menú `…` requiere medir ancho en JS.
- [~] **f2-lightbox-dialog** (M) — **Diferido:** focus-trap en photosLightbox.js (reutilizable del patrón de modal.js).
- [~] **f2-photo-tile-keyboard** (M) — **Diferido:** tiles enfocables/operables (JS).
### Superficies externas: estados — login cubierto
- [x] **f2-ext-focus** (M) — Cubierto por el baseline global `:focus-visible` de a11y.css (las 3 páginas cargan main.css); inputs ya tienen anillo border/box-shadow.
- [x] **f2-login-submit-loading** (M) — Login: `.is-loading`+`aria-busy`+disabled en submit, reset en `finally` ([auth.js](../static/js/features/auth/auth.js)) + CSS de spinner en `.auth-button`. _`node --check` OK._
- [x] **f2-ext-aria** (M) — `autocomplete` en login (`username`/`current-password`/`email`). _register/admin-setup: follow-up._
- [~] **f2-share-loading / f2-device-loading** (M) — **Diferidos:** mismo patrón, falta cablearlo en publicShare.js / device-verify.js.
- [~] **f2-error-icons** (L) — **Diferido:** componente de banner con icono líder (CSS+HTML).
- [x] **f2-consolidate-ctas** (M) — **Hecho (tras revisar capturas):** el login tenía **dos botones primarios gradiente** compitiendo; el de magic-link pasa a **secundario** (`.auth-button-secondary`, tinte/ghost) → una sola acción primaria por pantalla.
- [x] **f2-progressive-magic-link** (M) — **Hecho:** el formulario de magic-link se **colapsa** tras un link discreto ("¿Sin contraseña? Recíbelo por correo", `#magic-link-toggle` con `aria-expanded`/`aria-controls`); al abrir, foco al email. Pantalla más tensa e intencional. _`node --check` OK._
### Pulido premium de superficies auth (tras capturas) ✅ — login + setup wizard
- [x] **f2-auth-brand-oxi** (S) — Wordmark con el **lockup "Oxi" en acento** (`.brand-oxi`, gradiente de marca con `background-clip:text`) en los 4 paneles — el tratamiento *ownable* del DESIGN-SYSTEM §3.
- [x] **f2-auth-field-icons** (M) — **Iconos guía** (user/mail/lock) en los 11 inputs vía **CSS `mask`** (data-URI sin color → token-safe; color por `--color-text-muted`, acento en `:focus-within`). 0 SVG inline en HTML, solo una clase modificadora.
- [x] **f2-auth-pw-toggle** (M) — **Show/hide de contraseña** (ojo) en los 5 campos password (`[data-pw-toggle]`, `aria-pressed`/`aria-label` localizada, icono eye/eye-off por máscara). _`node --check` OK._
- [x] **f2-auth-pw-match** (M) — **Indicador de coincidencia en vivo** bajo "Confirmar contraseña" (register + admin): check verde / cruz roja + texto (`auth.passwordsMatch` / `auth.passwords_mismatch`), `aria-live="polite"`.
- [x] **f2-auth-stepper-connector** (S) — Stepper del setup → **grid 3-col con línea-track conectora** + halo del paso activo (antes los círculos flotaban sueltos).
- [x] **f2-auth-input-definition** (S) — Borde de input a `--color-border-medium` (definición), placeholder a `--color-text-muted` (legibilidad); foco con anillo acento ya existía.
- [x] **f2-auth-divider-press** (S) — Divisor "o" → **uppercase/tracked/muted**; press táctil del botón (`translateY(1px)`); glow del tile suavizado (spread negativo). 2 claves i18n nuevas (`magicLinkToggle`, `passwordsMatch`) en **los 16 locales** (es/en propias) → `check-locales` verde.
- [x] **f2-auth-ambient** (M) — **Fondo atmosférico premium** centralizado en `--brand-ambient` (spotlight con `bg-surface` + mesh de marca) + `--brand-grain` (ruido fractal desaturado, `mix-blend overlay`, token-safe). Aplicado a login/share/device (mata la duplicación 3×); grano por ahora solo en login. _lightningcss minifica el data-URI sin fallar._
- [x] **f2-auth-readonly** (S) — Campo `[readonly]` (admin user) ya no se atenúa como placeholder: texto full-strength + fill "locked" (`--color-bg-input-alt`).
### Pulido tras 2.ª ronda de capturas (app completa) ✅
- [x] **f3-share-i18n** (M) — **El modal de Compartir salía en inglés** (faltaban 17 claves `share.*`/`actions.*`; el JS ya usaba `i18n.t` con fallback). Añadidas a **los 16 locales** (es propio + inglés de fallback) → modal en español (Compartir:, Personas, Enlaces públicos, Puede ver, Sin caducidad, Aplicar, Añadir…). `check-locales` verde.
- [x] **f1-nav-active-unify** (S) — Iconos del sidebar: **estado activo siempre en acento** (antes el icono activo cambiaba de color por `nth-child` → cal-7/8/9…). Decisión del usuario: **mantener colores en reposo** (tipo Notion), unificar solo el activo.
- [~] **f1-nav-avatar** (S) — **Mantenido a propósito:** el color del avatar es **por-usuario** (hash de `userId` → `uv-color-N`, patrón Slack/Google = identidad visual), no un bug. No se fuerza a acento.
### Correcciones del dashboard de archivos (3.ª ronda de capturas) ✅
- [x] **f-bug-date-1970** (S) — **BUG REAL:** la fecha "Modificado" salía **21/1/1970**. Causa: [resourceList.js](../static/js/components/resourceList.js#L623) hacía `formatDateTime(new Date(dateVal))` — `modified_at` es Unix en **segundos** (~1.7e9), `new Date()` lo tomaba como **ms** → epoch+21d. Fix: pasar `dateVal` directo (el formatter ya convierte seg→ms). _Verificado: OLD→1970, NEW→2025; `node --check` OK. Otros renders sin el antipatrón; `profile.js` usa ISO (no afectado)._
- [x] **f-grid-thumbnails** (M) — Miniaturas de la rejilla: `.file-icon` era fijo 100×70 (imagen pequeña, letterbox) → ahora **full-width con `aspect-ratio: 4/3`** + el `.file-thumb` (object-fit:cover) **llena edge-to-edge** (rejilla uniforme tipo Drive); iconos de no-imagen centrados sobre fondo placeholder.
- [x] **f-grid-name-ellipsis** (S) — El ellipsis estaba en `.name-cell` pero el texto va en un `span` (block) → no aplicaba. Movido al `span` (`nowrap/overflow/ellipsis`) + **`title`** en los nombres (folder+file) para tooltip al truncar.
- [x] **f-grid-meta** (S) — El label redundante **"Imagen"** bajo cada card (ya se ve por la miniatura) → reemplazado por el **tamaño** (`size-cell`) como metadato secundario. _(Carpetas muestran "--" en rejilla — edge case menor.)_
- [x] **f-files-skeleton** (M) — Skeletons cableados en la carga de archivos (ver `f2-files-skeleton`) → sin flash de lista vacía mientras cargan miniaturas/datos.
- [x] **f-breadcrumb-home** (S) — El home folder mostraba su nombre técnico "My Folder - admin" → ahora muestra la etiqueta amigable **"Archivos"** (reutiliza `nav.files`, ya en los 16 locales; el nombre real queda como `title`).
### Card premium de la rejilla (4.ª ronda — "producto final") ✅
- [x] **f-card-premium** (M) — Redesign de la grid card: **borde hairline 1px → acento al hover** (antes 2px gris), **hover signature** (`translateY(-3px)` + sombra suave grande + **miniatura `scale(1.04)`** dentro del `overflow:hidden`), **anillo interior** en la miniatura (`inset 0 0 0 1px`), tipografía `text-sm`, transiciones con tokens de motion.
- [x] **f-card-overlay-actions** (M) — Estrella/kebab/checkbox/badge-shared **ya no flotan en la esquina de la card** → reposicionados **sobre la miniatura** con **scrim frosted** (`--color-scrim-control` nuevo + `backdrop-filter`): checkbox top-left, estrella+kebab top-right, badge-shared top-left bajo el checkbox.
- [x] **f-dropzone-explicit** (M) — El `#dropzone` (mostrado al arrastrar archivos vía ui.js) pasa de caja inline pequeña a **overlay full-area** sobre el área de contenido (`position:fixed`, offset del sidebar, borde punteado, backdrop frosted, icono acento grande, "Arrastra archivos aquí"). `pointer-events:none` en hijos evita el flicker de dragleave.
- [~] **f-grid-share-download-btns** (S) — **Diferido (opcional):** botones dedicados de compartir/descargar al hover vía `customActions` — pero ese mecanismo es **global** (afecta lista + carpetas), necesita scoping grid-only/file-only. El kebab ya cubre la función (el usuario lo marcó opcional).
- [—] **f-grid-select** — **Ya existía:** selección por checkbox al hover en rejilla.
- [x] **f-bug-tooltip-orphan** (S) — **BUG REAL:** el tooltip del email del avatar se quedaba pegado al salir el ratón. Causa: `_mountAvatarVignettes` re-renderiza la vignette con `replaceChildren` (p.ej. al actualizarse el storage), pero el popover de `attachTooltip` está portalado a `document.body` y nadie llamaba a su cleanup → quedaba huérfano y visible. Fix: WeakMap de cleanups + `disposeVignette()` que se llama sobre el hijo viejo antes de re-montar. _`node --check` OK._
### Pulido del menú de usuario (desplegable) ✅
- [x] **f-menu-no-avatar-tooltip** (S) — Opción `noTooltip` en la vignette → el avatar propio del topbar **ya no muestra el tooltip del email** (redundante con el header del menú + solapaba la campana). Mata la causa del bug y el "tell" de redundancia.
- [x] **f-menu-appearance-icon** (S) — Icono líder de "Apariencia" `fa-moon` → **`fa-adjust`** (medio-círculo de tema/contraste; FA5+FA6) — neutro, ya no implica modo oscuro.
- [x] **f-menu-row-focus** (S) — `:focus-visible` en las filas del menú (mismo tinte que hover) → navegación por teclado clara.
- [—] **f-menu-segmented-aria** — **Ya existía:** los 3 botones del segmented (sol/escritorio/luna) ya tenían `aria-label` i18n (light/auto/dark).
- [~] **f-menu-role-chip** (S) — **Diferido:** mover el badge "Admin" junto al nombre en el header requiere reestructurar la vignette (generada por JS). Bajo valor / más riesgo.
- [~] **f-menu-shortcut-hints** (S) — **Diferido:** mostrar atajos (⌘,) sin que existan los atajos reales sería una afordancia falsa; primero habría que cablear los shortcuts.
### Pulido premium de la list view (tras captura) ✅
- [x] **f-list-thumb-ring** (S) — Miniaturas de lista a 40×40 + **anillo interior** (`inset 0 0 0 1px`) → consistente con la rejilla premium (no sangran sobre la fila).
- [x] **f-list-row-accent** (S) — **Barra-acento izquierda** en hover/selected (`inset 3px 0 0 var(--color-accent)`, sin layout shift) + transición → énfasis "esta fila".
- [x] **f-list-hover-actions** (S) — Kebab **revelado al hover** (`@media (hover:hover)`; en táctil sigue visible; visible en `:focus-within` para teclado) → filas más limpias.
### Última milla del login (tras captura) ✅
- [x] **f-login-bg-stronger** (S) — **El fondo se veía blanco/amateur** → `--brand-ambient` reforzado (glow 0.13→0.20 + **4 blobs cálidos** que llenan el lienzo, no solo esquinas) + grano a 0.6 → canvas dimensional, ya no plano.
- [x] **f-login-autofocus** (S) — **Autofocus** en el campo usuario al mostrar el panel (los paneles arrancan ocultos → focus por JS, no atributo) — cursor listo al cargar y al volver desde registro.
- [x] **f-login-capslock** (S) — **Aviso "Bloq Mayús activado"** bajo los campos de contraseña (login/register/admin) vía `getModifierState('CapsLock')` — evita el login fallido. Clave `auth.capsLock` en los 16 locales.
- [—] **f-login-sso** — **Ya existía:** botón SSO condicional (`configureOidcLoginUI` cuando hay OIDC).
- [~] **f-login-remember** (S) — **Diferido:** "Recordarme" necesita **soporte backend** (no hay param remember/persistent en `/api/auth/login`; sería un checkbox falso). Requiere TTL de refresh-token configurable.
- [—] **f-login-forgot** — **N/A:** no hay self-service password-reset (solo `admin_reset_password`). El **magic-link ES la vía de recuperación** sin contraseña — un "¿olvidaste?" apuntaría a nada.
- [x] **f-list-sortable-headers** (M) — **Hecho (opción A, sort plano tipo Drive):** las cabeceras Nombre/Tipo/Tamaño/Modificado son **clicables** → flat sort asc/desc (sin swimlanes), con **flecha** en la columna activa que invierte al re-clicar. Nuevo path en `filesView` (`_sortField` que sobreescribe el `order_by` del group-by y fuerza `_groupBy=''`; `setSortField` + getter `currentSort`); el header se sincroniza desde `_loadPage` (header click / toolbar group-by / dirección). `aria-label` localizado con dirección (`sort.asc/desc` en los 16 locales). _Verificado: state machine (click size→asc, re-click→desc, group type→limpia flat), node --check, build 0 errores._
- _Nota: el sort plano usa `order_by` server-side (el agrupado en swimlanes del toolbar sigue disponible aparte). El botón de dirección del toolbar ahora también re-sincroniza la flecha vía `_loadPage`._
### Panel de notificaciones (tras captura) ✅
- [x] **f-notif-i18n** (L) — **El idioma mezclado del panel** (notificaciones en inglés en app española): **52 `showNotification('inglés')` hardcoded** en 8 archivos → convertidas a `i18n.t` vía **workflow multi-agente** (1 agente/archivo, en paralelo) + manejo de interpolación (`${var}`→`{{param}}`). **67 claves `notif.*` nuevas** traducidas al español y añadidas a **los 16 locales** (es propio + inglés de fallback). _Verificado: node --check los 8, 0 hardcoded restantes, todas las claves referenciadas cubiertas, `check-locales` verde, build 0 errores._
- [x] **f-notif-scrollbar** (S) — Scrollbar del panel: del **global naranja grueso** a uno **fino gris (6px)** específico (`.notif-panel-body`).
- [~] **f-notif-relative-time / f-notif-fav-icon / f-notif-read-state** (S) — **Pendientes (menores):** hora relativa ("hace 5 min" con `formatRelativeTime`, requiere guardar timestamp real), color del icono de favorito (verde→dorado), y estado leído/no-leído.
### Bug del visor de imágenes (tras capturas) ✅
- [x] **f-bug-viewer-overflow** (S) — **BUG REAL:** las imágenes anchas se **desbordaban** del visor (scroll horizontal + barra naranja gruesa) → parecía que "se repetían" al scrollear. Causa: `.inline-viewer-image` es flex item y el `min-width: auto` por defecto de flexbox **ignora el `max-width: 100%`** (no encoge). Fix: `min-width: 0; min-height: 0` → la imagen ajusta entera, sin scroll. + **scrollbar fino/sutil** en el visor (para el caso de zoom; reemplaza el global acento grueso). _Build 0 errores._
### Rendimiento (foco en shell)
- [~] **f2-icon-foit** (L) — **Diferido:** eliminar el FOIT de iconos del shell requiere inlinear iconos críticos o un build — relacionado con el grupo de build/perf pendiente.
---
## FASE 3 — Pulido (de "sólido" a "premium")
### Empty states con CTA + ilustración — CSS compartido mejora TODAS
- [x] **f3-empty-component** (M) — `.empty-state` mejorado ([content.css](../static/css/layout/content.css)): icono **accent** (era gris apagado), primer `<p>` como título (lg/semibold/heading), desc muted con medida, spacing de CTA. Como todas las vistas comparten `.empty-state`/`.empty-state-icon`, **mejora las 7 a la vez** sin tocar su JS.
- [x] **f3-empty-files** (M) — CTA **"Upload files"** cableado en `showEmptyList` (ui.js) → abre el file picker. _`node --check` OK._
- [~] **f3-empty-photos / f3-empty-search / f3-empty-trash / f3-empty-rest** — **Visualmente mejoradas** por el CSS compartido; el **CTA propio** de cada una (subir fotos / limpiar búsqueda / etc.) queda de follow-up (JS por vista). Ilustraciones SVG bespoke → Fase 4.
### Fotos y lightbox (nivel Google Photos)
- [x] **f3-photo-placeholder** (M) — `decoding="async"` + fondo placeholder muted en `.photo-tile` + **fade-in al cargar** (`_fadeInTiles` chequea `img.complete`, listeners `once`) → se acaba el "pop-in". _`node --check` OK._
- [x] **f3-lightbox-preload** (M) — `_preloadNeighbors()` precarga el thumbnail prev/next en cada `_show` → navegación instantánea.
- [x] **f3-lightbox-spinner** (S) — Ya estaba: spinner girando (`.photos-loading i` con `spin`) + fallback "Failed to load" en error (verificado).
- [~] **f3-justified-grid** (L) — **Diferido:** grid justificado necesita aspect-ratios + algoritmo de packing (feature grande).
- [~] **f3-lightbox-zoom** (L) — **Diferido:** zoom/pan/doble-tap (gestos, JS no trivial).
- [~] **f3-lightbox-swipe** (L) — **Diferido:** swipe táctil con rebote (gestos).
### Refinamiento visual y movimiento — wins CSS aditivos hechos
- [x] **f3-listrow-hover** (S) — Transición en `.file-item` (bg/border) **rutada por `--motion-fast`+`--ease-standard`** (curva de desaceleración) → las filas ya no "snapean". El grid mantiene su transición de transform.
- [x] **f3-text-balance** (S) — `text-wrap: balance` en headings + `pretty` en prosa (empty-state, about, auth) en [typography.css](../static/css/base/typography.css).
- [x] **f3-tabular-nums** (M) — `font-variant-numeric: tabular-nums` en storage/badge/stat-value vía una regla central (size-cell y música ya lo tenían).
- [~] **f3-prose-measure** (M) — Parcial: `text-wrap: pretty` aplicado; la medida (max-width) ya la acotan los contenedores estrechos + `.empty-state p` (65ch explícito → Fase 1 via `.prose`).
- [~] **f3-filetype-glyphs / f3-dragdrop / f3-search-results / f3-toast-exit / f3-overlay-travel / f3-spinner-primitive / f3-shadow-adoption / f3-hover-active-press / f3-dark-elevation / f3-display-clamp** — **Diferidos:** todos **cambian valores visuales existentes** de forma amplia (sombras, easing, glifos, elevación) → necesitan verse renderizados antes. _El barrido de motion (rutar transiciones por `--ease-*`) y el de sombras (→ tokens `--shadow-*`) son los de mayor impacto pendiente._
### Login/share atmosféricos + marca — primera impresión CSS hecha
- [x] **f3-login-bg** (L) — Fondo del login: gris plano → **gradiente atmosférico** (dos radiales de brand-glow sutil sobre `--color-bg-page`). En dark, el glow cálido sobre el navy se ve premium.
- [x] **f3-share-bg** (M) — Mismo gradiente en share **y** device-verify (antes `bg-hover` plano) → primera impresión coherente.
- [x] **f3-card-elevation-ext** (M) — Las 3 tarjetas externas (auth-panel/share-card/dag-card) unificadas a `box-shadow: var(--shadow-xl)` + `border-radius: var(--radius-3xl)`.
- [x] **f3-share-trust-footer** (M) — Footer estático "🔒 Secured by OxiCloud" en la página de share. _Expiry/password badge dinámicos → follow-up JS._
- [x] **f3-logo-motion** (S) — El hover del logo ya respeta reduced-motion (vía el guard global de a11y.css).
- [~] **f3-share-attribution** (M+S) — **Diferido:** "compartido por" requiere exponerlo en la API (backend Rust) + render.
- [~] **f3-share-preview** (L) — **Diferido:** preview inline de archivo (JS + thumbnail).
- [~] **f3-device-code-segments** (M) — **Diferido:** input OTP segmentado (JS no trivial).
- [~] **f3-share-empty-error** (M) — **Diferido:** mejorar empty/error de la galería (JS/CSS).
- [~] **f3-brand-voice / f3-brand-voice-i18n** (M) — **Diferidos:** definir voz/tagline + reescribir "Acerca de" + propagar a 16 locales (contenido, decisión de marca).
### Marca: favicon / PWA / OG — wiring hecho (faltan rásters que no puedo generar)
- [x] **f3-pwa-manifest** (M) — [manifest.webmanifest](../static/manifest.webmanifest) + **iconos PNG maskable 192/512** rasterizados (vía `qlmanage` de macOS) → PWA instalable en todas las plataformas. Mantiene los SVG (`any`) + theme-color light/dark.
- [x] **f3-favicon-set** (M) — `apple-touch-icon` ahora apunta a **PNG 180×180** rasterizado (iOS ya muestra el icono al "añadir a inicio"; antes ignoraba el SVG). + SVG + favicon.ico + mask-icon en index/login/share.
- [x] **f3-og-image** (M) — **`og-image.png` 1200×630** rasterizado (recortado del thumbnail cuadrado de qlmanage); `og:image`/`twitter:image` apuntan al PNG en index/login/share → **previews reales** al compartir (las redes ignoraban el SVG).
- **✅ RESUELTO (antes el gap nº1 del audit):** los 3 PNG se generaron con `qlmanage -t -s N` (Quick Look rasteriza SVG en macOS) + `sips` para recortar el OG. Dimensiones exactas verificadas (180²/192²/512² + 1200×630), copiados a `static/logo/`, refs actualizadas. _iOS/redes/PWA ya no rotos._
### Responsive: pulido — CSS + a11y del drawer hecho
- [x] **f3-mobile-touch** (M) — `@media (max-width:768px)`: **≥44px** en toggle/search/notif/avatar, nav-items y `min-height:48px` en filas de lista (a11y.css).
- [x] **f3-drawer-focus-trap** (M) — Toggle con `aria-controls`+`aria-expanded` (sincronizado en open/close); foco al primer nav-item al abrir y **restaurado al toggle** al cerrar; **trap de Tab** dentro del drawer abierto (navigation.js). _`node --check` OK. `inert` de fondo omitido (el toggle vive en el fondo) — el overlay + trap lo cubren._
- [x] **f3-drawer-motion** (S) — Transición del sidebar → `var(--motion-slow) var(--ease-emphasized)` (curva de desaceleración, value-preserving 0.3s).
- [x] **f3-scrollbar-gutter** (S) — En móvil `scrollbar-gutter: auto` (sin gutter fantasma con scrollbars de overlay).
- [~] **f3-rtl-breakpoints** (M) — **Diferido:** verificación de paridad RTL en cada breakpoint → necesita render.
- [~] **f3-list-secondary-line** (M) — **Diferido:** línea secundaria (tamaño·fecha) en la fila móvil → restructura que conviene ver renderizada.
### Rendimiento (assets)
- [x] **f3-srcset-photos** (M) — Tiles con `srcset` (icon 150w / preview 400w / large 800w) + `sizes` → sirve la resolución justa (icon en móvil denso, large en retina). CLS ya lo cubre `aspect-ratio:1`. _`node --check` OK._
- [x] **f3-sw-strategy** (L) — **Navigation preload** añadido (enable + uso de `preloadResponse`) + bump de caché v27. El resto ya existía (precache versionado, `skipWaiting`, `clients.claim`, network-first HTML, stale-while-revalidate assets).
- [~] **f3-thumb-webp-avif** (L) — **Diferido:** WebP **lossy** (el que ahorra) necesita el crate `webp`/libwebp (dep nueva); el `image-webp` presente solo hace decode/lossless (no ayuda en fotos). AVIF necesita `ravif`. Decisión de dependencia.
- [~] **f3-lcp-preload** (M) — **Diferido:** en Photos/share la URL del LCP es **dinámica** (se conoce tras fetch JS) → no se puede `<link rel=preload>` estático sin lógica extra.
- [~] **f3-dead-css** (L) — **Diferido:** barrido de CSS muerto es arriesgado sin coverage tooling (clases generadas por JS → falsos positivos).
---
## FASE 4 — Clase mundial + guardarraíles (alcanzar y SOSTENER el 10)
### Interacciones de producto premium
- [x] **f4-command-palette** (L) — **Command palette global (Cmd/Ctrl+K)** ([commandPalette.js](../static/js/app/commandPalette.js) + [css](../static/css/components/commandPalette.css)): navegación a las 8 secciones + acciones (upload/profile/about/logout/admin), filtro por substring, ↑/↓/Enter/Esc, focus restaurado. **Ejecuta clicando los controles reales** → no se desincroniza. _`node --check` OK._
- [x] **f4-detail-polish** (S) — `::selection` con tinte de marca + `caret-color` acento (scrollbar ya era custom).
- [~] **f4-shortcut-grammar / f4-shortcut-help** (M/S) — Cmd+K ya es un atajo global; el sistema completo + overlay `?` quedan de follow-up.
- [~] **f4-view-transitions** (M) — **Diferido (doable):** envolver los cambios de sección en `document.startViewTransition()` (progresivo) — conviene verlo.
- [~] **f4-undo-destructive / f4-optimistic-everywhere / f4-dnd-finesse / f4-bulk-progress / f4-delight / f4-lightbox-filmstrip / f4-lightbox-shared-element / f4-empty-illustrations / f4-btn-signature / f4-high-dpi** — **Diferidos:** features grandes (undo/optimista/bulk), gestos/transiciones complejas (filmstrip/shared-element/dnd), o pulido que conviene ver renderizado.
### Internacionalización de verdad (16 locales) — Intl (sin deps)
- [x] **f4-i18n-format** (M) — `formatFileSize` → `Intl.NumberFormat` (separadores locale); `formatDateTime`/`formatDateShort` → pasan el **locale de la app** (antes el del navegador) en [formatters.js](../static/js/core/formatters.js).
- [x] **f4-i18n-relative-time** (S) — `formatRelativeTime()` con `Intl.RelativeTimeFormat` (CLDR, localizado) + **consolidados** los 2 `timeAgo` duplicados de admin/profile a usarlo (manteniendo el caso "never"). _`node --check` OK._
- [x] **f4-i18n-completeness-ci** (S) — [scripts/check-locales.mjs](../scripts/check-locales.mjs): claves faltantes/sobrantes + integridad de `{placeholders}`. Wireado en `just frontend-check`. **nl.json corregido → la suite pasa ✓** (los 6 mismatches igualados a en per-clave). _Hallazgo aparte: en.json mezcla `{{}}` y `{}` single en algunas claves (`actions.upload.complete`, `groups.member_count_other`) → bug latente de interpolación, consistente en los 16 → tarea i18n aparte._
- [~] **f4-i18n-plural** (L) — **Diferido:** `Intl.PluralRules` sin valor sin las formas plurales traducidas por locale (datos i18n).
- [~] **f4-i18n-collation** (M) — **Diferido:** no se encontró sort de nombres cliente (probablemente server-side).
- [~] **f4-i18n-bidi** (M) — **Diferido:** `unicode-bidi: isolate`/`dir="auto"` en nombres de usuario (CSS/JS contenido, follow-up).
- [~] **f4-i18n-rtl-logical** (XL) — **Diferido:** barrido físico→lógico (margin-left→margin-inline-start…) en toda la CSS.
### Offline, errores, onboarding, voz
- [x] **f4-error-boundary** (M) — [errorBoundary.js](../static/js/core/errorBoundary.js): `window.error` + `unhandledrejection` → toast throttled (`.error-toast`) + log a consola. Dependency-free, cargado primero. _`node --check` OK._
- [x] **f4-print** (M) — `@media print` en a11y.css: oculta chrome (sidebar/topbar/actions/cmdk) y limpia sombras para imprimir el contenido.
- [~] **f4-offline-shell / f4-offline-queue** (L/XL) — **Diferidos:** banner online/offline + cola durable de mutaciones (feature grande, termina el stub de background-sync del sw).
- [~] **f4-error-quota / f4-onboarding / f4-microcopy** (M/L) — **Diferidos:** estados de quota, primer-uso, y sistema de microcopy (contenido/feature).
- [x] **f4-live-region-async** (M) — **Hecho** (ver `f2-live-region`): región `aria-live` global que anuncia las notificaciones asíncronas.
- [~] **f4-pwa-install** (M) — **Diferido (doable):** prompt `beforeinstallprompt` + botón de instalar (tiene componente visual → mejor decidir ubicación con la app delante).
- [~] **f4-sr-flow-pass** (L) — **Diferido:** test manual con lector de pantalla (no automatizable aquí).
### Design system documentado (la fuente de verdad)
- [x] **f4-token-docs** (L) — [scripts/gen-token-docs.mjs](../scripts/gen-token-docs.mjs) → [docs/TOKENS.md](TOKENS.md) (**430 tokens, 20 grupos**, auto-generado).
- [x] **f4-a11y-docs** (S) — Sección de accesibilidad codificada en [docs/DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) §2.
- [x] **f4-brand-guide** (M) — Mini-guía de marca (mark/wordmark/accent/clear-space/do-don'ts/maskable/OG) en DESIGN-SYSTEM.md §3.
- [~] **f4-perf-docs** (M) — **Parcial:** los guardarraíles documentados; falta la arquitectura de build/perf (depende de formalizar el `static-dist`).
- [~] **f4-gallery-page / f4-ds-site / f4-gallery-vr-axe** (XL/L/M) — **Diferidos:** styleguide vivo + snapshot/axe → necesitan tooling/build.
- [x] **f4-brand-drift-ci** (M) — **Hecho:** [check-brand-drift.mjs](../scripts/check-brand-drift.mjs) bloquea cambios silenciosos del logo (hash sha256 de `logo-plain.svg`) y del gradiente (`--color-logo-gradient`) contra un baseline bloqueado; en `just frontend-check`. **Pasa ✓**.
- [~] **f4-token-parity** (M) — **Diferido:** contrato de paridad con assets de diseño exportados (necesita los assets de diseño como fuente).
- [~] **f4-variable-font** (XL) — **Diferido:** evaluar/adoptar fuente variable self-hosted (decisión + asset).
- [~] **f4-opentype** (S) — **Diferido:** features OpenType (limitado en system stack; útil con fuente variable).
### Guardarraíles de CI — los sin-deps entregados y PASANDO
- [x] **f4-contrast-ci** (L) — [scripts/check-contrast.mjs](../scripts/check-contrast.mjs): resuelve `light-dark()`+alias y **falla si cualquier par texto/fondo o semántico baja de 4.5:1** en light o dark. **Pasa ✓**.
- [x] **f4-heading-order-ci** (M) — [scripts/check-headings.mjs](../scripts/check-headings.mjs): exige ≥1 `h1` y sin saltos de nivel por página. **Pasa ✓**.
- [x] **f4-dead-token-sweep** (M) — [scripts/check-dead-tokens.mjs](../scripts/check-dead-tokens.mjs): reporta tokens definidos sin `var()` (informativo, excluye prefijos dinámicos). **Encontró 96 candidatos** a podar.
- [x] **f4-ci-single-gate** (M) — Recipe `just frontend-check` que corre los 4 scripts (contrast/headings/locales/dead-tokens). _Falta añadir stylelint/biome/tsc cuando haya `node_modules`._
- [~] **f4-stylelint-scales / f4-lint-focus / f4-lint-breakpoints** (L/M) — **Pendiente:** reglas escribibles, pero **activarlas rompe CI hasta migrar los 184 px off-grid** (f1-snap-fractional) — hacerlo después de esa migración.
- [~] **f4-axe-ci / f4-keyboard-ci / f4-visual-regression / f4-dark-parity / f4-crossbrowser / f4-lighthouse-ci** — **Diferidos:** necesitan Playwright/axe/Lighthouse (**deps + browsers**, sin `node_modules` aquí). Decisión de tooling.
- [x] **f4-brand-drift-ci** (M) — **Hecho** (consolidado arriba): [check-brand-drift.mjs](../scripts/check-brand-drift.mjs) en `just frontend-check`.
> _Nota: este grupo se consolidó arriba (sección «Design system documentado»). El
> bloque duplicado de planificación original se eliminó para no inflar el conteo de
> pendientes — `f4-token-docs`/`a11y-docs`/`brand-guide` están hechos; el resto
> (gallery-page/ds-site/gallery-vr-axe/token-parity/variable-font/opentype/perf-docs)
> está diferido con su razón en la sección de arriba._
---
## Camino crítico (el orden que de verdad importa)
```
f0-stylelint-typo ─┐ (sin esto, nada se lintea)
f0-space/radius/type/motion/elevation/zindex ─┬─► f1-mig-* (migración) ─► f2-focus/contrast/nav ─► f4-stylelint/axe/contrast-ci
f0-oklch-ramps ────► f0-semantic-* ────────────┴─► f1-demote-*/fileicon-* (paleta)
f0-brand-mark ─────► f1-logo-* ───────────────────► f1-about-real-mark
f0-perf-baseline ──► f1-critical-css ─────────────► f4-lighthouse-ci
```
> **Nota de constraint (CLAUDE.md) — actualizada 2026-06:** el proyecto **SÍ tiene build pipeline**
> ([`build.rs`](../build.rs): bundling + content-hash + minify vía crates Rust lightningcss/oxc,
> **sin npm**). Lo que de verdad sigue necesitando devDependencies npm / navegadores es solo el
> **tooling de test de UI**: Playwright (keyboard/visual-regression/cross-browser), axe-core
> (a11y automatizada) y Lighthouse-CI (presupuestos de perf). WebP lossy necesita el crate `webp`.
> El resto de "diferidos por build" en realidad están bloqueados por: render-review (critical-CSS),
> complejidad/ROI (code-split), o el servido de CSS crudo en dev (`@custom-media`). CLAUDE.md
> prohíbe "npm deps no listadas" → el tooling de test necesita aprobación explícita; el riesgo está
> acotado a `devDependencies` (no toca el runtime ni el binario).
## Resumen por área (de la auditoría)
| Área | Nota actual | Tareas |
|------|:-----------:|:------:|
| Tokens / fundamentos | 3.0 | 29 |
| Tipografía | 3.0 | 43 |
| Color / paleta | 4.0 | 32 |
| Accesibilidad | 3.0 | 35 |
| Layout / responsive | 4.5 | 34 |
| Componentes / movimiento | 4.5 | 39 |
| Auth / primera impresión | 4.5 | 41 |
| Vistas núcleo | 4.0 | 41 |
| Marca / cohesión | 3.5 | 25 |
| Rendimiento | 6.5 | 15 |
| Tooling / CI / docs | 5.5 | 36 |
| Completitud (lo que faltaba) | — | 32 |
| **Total** | **4.8** | **402** |