FRAMR.WALKTHROUGHENES

00 / GUÍA DEL CÓDIGO

CÓMO
FUNCIONA
ESTO.

Lectura técnica de FRAMR. Cada decisión, cada archivo, cada pedacito de mate explicado.

Esto no es un README marketinero, es la guía interna del proyecto. Para entender por qué algo está armado como está, está acá. Orden recomendado de lectura: top to bottom, aunque cada sección se sostiene sola.

01 / Vista de pájaro

FRAMR es una app 100% cliente. No hay backend, no hay base de datos, no hay subida a ningún servidor. Todo el procesamiento de imágenes ocurre en el browser usando la Canvas API.

El flujo es simple:

  1. El usuario sube una imagen (drag-drop o file picker).
  2. El browser la carga en un HTMLImageElement y guarda sus dimensiones.
  3. Para cada uno de los 23 presets de plataformas, se renderiza un preview interactivo.
  4. El usuario reposiciona/zoomea cada frame con drag + scroll/slider.
  5. Cuando descarga, se vuelca a un <canvas> del tamaño exacto del preset, se convierte a WebP con canvas.toBlob(), y se gatilla un download.
  6. El ZIP se arma con JSZip en el browser, también sin red.
IMAGENPNG · JPG · WEBPHTMLImageElementnaturalW · naturalHblob URLCatálogo presets23 formatos · lib/platformsTransforms statezoom · cx · cy por presetPresetCard ×23preview interactivodrag + zoom.WEBP+ZIPcomputeFrame(): la misma funciónpara el preview Y para el canvas de export
¿POR QUÉ ASÍ?La regla más importante: la imagen original nunca sale del browser. Es un argumento de privacidad fuerte (no necesita explicaciones para creators) y además ahorra el costo de un backend. Todo lo que necesita Canvas API ya está en el browser hace años.

02 / Mapa de archivos

La app es chica, pero todo tiene su lugar:

src/ ├── app/ │ ├── layout.tsx # fuentes, skip-link, lang="en" │ ├── globals.css # tokens, focus-visible, reduced-motion │ ├── page.tsx # app principal (Uploader + grid de PresetCards) │ ├── page.module.css │ ├── ui-kit/ │ │ ├── page.tsx # /ui-kit: showcase del design system │ │ └── page.module.css │ └── walkthrough/ │ ├── page.tsx # /walkthrough: este mismo doc (EN) │ └── es/page.tsx # /walkthrough/es: versión ES ├── components/ │ ├── Header.tsx # FRAMR. + WALKTHROUGH + botón UI KIT │ ├── Header.module.css │ ├── Uploader.tsx # drag & drop + picker │ ├── Uploader.module.css │ ├── PresetCard.tsx # preview interactivo + download por preset │ └── PresetCard.module.css └── lib/ ├── platforms.ts # los 23 presets (Instagram, TikTok, etc.) ├── transform.ts # matemática del crop (zoom·cx·cy → tx·ty·scale) └── export.ts # canvas → WebP → ZIP

Reglas que mantengo en este árbol:

  • Componentes en components/, lógica pura en lib/, páginas en app/. Sin utils.ts cajón de sastre.
  • Cada componente tiene su .module.css al lado. Sin Tailwind. Cada clase queda scopeada por archivo.
  • Nada en lib/ importa de components/. Solo en una dirección.

04 / El modelo de transform

Esta es la pieza más importante de todo el proyecto. Acá viven las matemáticas que hacen que el crop sea coherente entre lo que se ve en pantalla y lo que se descarga.

El problema

Necesitamos representar "cómo está cropeada la imagen en este preset" con un objeto chico, que sea independiente de las dimensiones del preview. ¿Por qué? Porque el preview se ve a 240px de display, pero el export se hace a 1080×1920 o 2560×1440. La misma "configuración de crop" tiene que servir para ambos.

La solución: 3 números

export type Transform = {
  zoom: number;  // >= 1, multiplicador encima del "cover fit"
  cx: number;    // 0..1, posición horizontal del centro en coords de imagen
  cy: number;    // 0..1, posición vertical del centro en coords de imagen
};

El truco mental:

  • zoom = 1 significa "la imagen cubre el frame exacto" (cover-fit).
  • zoom = 2 significa "el doble de grande" → vemos menos imagen pero con más detalle.
  • cx = 0.5, cy = 0.5 es el centro de la imagen apuntando al centro del frame.
  • cx = 0.2 es "estoy mirando el 20% horizontal de la imagen", o sea que el foco se corre a la izquierda.
¿POR QUÉ NO GUARDAR tx, ty, scale?Porque esos valores están atados a un tamaño de frame. Si guardás tx = 120px, eso significa cosas distintas según si el frame es de 240px o de 1080px. Guardando un punto focal normalizado (0..1) y un zoom relativo, la misma config funciona para cualquier tamaño de output. Es resolución-independiente.

05 / computeFrame(), paso a paso

Esta función toma un transform abstracto y un tamaño de frame, y devuelve los valores concretos en píxeles para renderizar.

export function computeFrame(
  imgW, imgH, frameW, frameH, t
) {
  // 1. La escala mínima que asegura cover (que la imagen cubra todo el frame).
  const base = Math.max(frameW / imgW, frameH / imgH);

  // 2. La escala efectiva incluye el zoom del usuario.
  const scale = base * t.zoom;
  const renderedW = imgW * scale;
  const renderedH = imgH * scale;

  // 3. Calculamos tx, ty para que (cx, cy) caiga en el centro del frame.
  let tx = frameW / 2 - t.cx * renderedW;
  let ty = frameH / 2 - t.cy * renderedH;

  // 4. Clamp: la imagen nunca puede dejar un borde vacío.
  const minTx = frameW - renderedW;  // más negativo posible
  const minTy = frameH - renderedH;
  if (tx > 0) tx = 0;
  if (ty > 0) ty = 0;
  if (tx < minTx) tx = minTx;
  if (ty < minTy) ty = minTy;

  return { scale, renderedW, renderedH, tx, ty };
}

Ejemplo con números reales

Imagen 2400×1600, preset 1080×1920 (vertical 9:16), zoom 1, cx 0.5, cy 0.5:

  1. base = max(1080/2400, 1920/1600) = max(0.45, 1.2) = 1.2: la imagen tiene que crecer 20% para cubrir vertical.
  2. scale = 1.2 * 1 = 1.2
  3. renderedW = 2400 * 1.2 = 2880, renderedH = 1600 * 1.2 = 1920 (justito).
  4. tx = 1080/2 - 0.5 * 2880 = 540 - 1440 = -900 (la imagen se mueve a la izquierda).
  5. ty = 1920/2 - 0.5 * 1920 = 960 - 960 = 0.
  6. El clamp: minTx = 1080 - 2880 = -1800. -900 está entre -1800 y 0, OK.

Resultado: la imagen se dibuja a tamaño 2880×1920, desplazada 900px a la izquierda, mostrando los 1080 píxeles del centro horizontal y los 1920 verticales. Cover perfecto.

El gemelo: clampedTransform()

Cuando el usuario arrastra o cambia el zoom, el cx/cy resultante puede caer fuera del rango válido (intentando ver más allá del borde). En vez de dejarlo y clampear solo en el render, ajustamos el cx/cy mismos antes de guardar el state. Eso mantiene los valores consistentes.

06 / Estado del page

Toda la app es un solo componente React (app/page.tsx) con tres bits de estado:

const [loaded, setLoaded] = useState<LoadedImage | null>(null);
const [transforms, setTransforms] = useState<Record<string, Transform>>({});
const [zipping, setZipping] = useState<string | null>(null);
  • loaded: la imagen ya cargada como HTMLImageElement + su nombre. Null cuando todavía no se subió nada.
  • transforms: un objeto keyed by preset id. Cada preset que el usuario tocó tiene su propio transform; los que no, usan DEFAULT_TRANSFORM ({ zoom: 1, cx: 0.5, cy: 0.5 }).
  • zipping: string con qué se está zippeando ("all", "instagram", etc.) o null. Sirve para deshabilitar otros botones mientras corre.
¿POR QUÉ HTMLImageElement Y NO UN BLOB URL?Porque necesitamos naturalWidth y naturalHeight mil veces para los cálculos. Tener el elemento ya cargado evita tener que crear y esperar imágenes nuevas en cada render. Además, ctx.drawImage() en el canvas acepta directamente un HTMLImageElement.

07 / Uploader

El componente más simple de la app, pero con un par de detalles de a11y.

Estructura

Un <div> con role="button" y tabIndex={0} que actúa como dropzone Y como trigger del file picker:

<div
  role="button"
  tabIndex={0}
  onDragOver={onDragOver}
  onDragLeave={onDragLeave}
  onDrop={onDrop}
  onClick={openPicker}
  onKeyDown={(e) => {
    if (e.key === "Enter" || e.key === " ") openPicker();
  }}
>
  <input type="file" ref={inputRef} hidden />
  <h1>DROP. AN. IMAGE.</h1>
  {/* ... */}
</div>
¿POR QUÉ NO USAR <button> DIRECTO?Por HTML semantics: un <button> no debería tener un <h1> ni un <input>adentro (la spec lo prohíbe). Con un div + role="button" + tabIndex + manejo de Enter/Space lográs lo mismo accesible sin romper la semantics. Lighthouse lo acepta.

Validación

Cuando llega un archivo, hacemos un check minimal:

if (!file.type.startsWith("image/")) {
  setError("THAT'S NOT AN IMAGE.");
  return;
}

No hay validaciones de tamaño máximo ni nada parecido. Siempre se pueden agregar más validaciones después según necesidad (un check de file.size, formatos específicos, lo que haga falta).

08 / PresetCard: el componente jugoso

Acá está casi todo el código interactivo. Es el responsable de mostrar el preview de un preset y permitir crop + download.

Anatomía

<article>                              <!-- card entero, a11y: aria-labelledby -->
  <header>                             <!-- label del preset + dimensiones target -->
    <h3>Feed Portrait</h3>
    <span>1080×1350</span>
  </header>
  <div>                                <!-- frame con overflow:hidden + drag handlers -->
    <img />                            <!-- imagen real, posicionada con transform -->
    <div>skeleton shimmer</div>        <!-- 400ms inicial -->
    <span>⚠ +28%</span>                <!-- badge si necesita upscale -->
    <span>↔ DRAG · ⇕ ZOOM</span>       <!-- hint en hover -->
  </div>
  <div>                                <!-- controles: slider de zoom + reset -->
    <input type="range" />
    <button>RESET</button>
  </div>
  <button>↓ GIMME .WEBP</button>
</article>

El frame muestra el preview a tamaño chico (MAX_DISPLAY = 240px en la dimensión más larga), pero matemáticamente representa el mismo crop que el canvas de export.

09 / Pan: cómo se arrastra

El drag usa Pointer Events (no mouse events). Pointer events funcionan unificados para mouse + touch + pen, y soportan setPointerCapture() que es clave.

El flow

// onPointerDown
(e) => {
  e.target.setPointerCapture(e.pointerId);  // el target retiene los eventos
  dragRef.current = {
    startX: e.clientX, startY: e.clientY,
    startCx: transform.cx, startCy: transform.cy,
    scale: math.scale,  // la escala efectiva actual
  };
};

// onPointerMove
(e) => {
  const d = dragRef.current;
  if (!d) return;
  const dx = e.clientX - d.startX;
  const dy = e.clientY - d.startY;
  // Convertimos delta en píxeles a delta en coords normalizadas de imagen.
  const cx = d.startCx - dx / (imgW * d.scale);
  const cy = d.startCy - dy / (imgH * d.scale);
  onTransformChange(clampedTransform(/* ... */, { zoom, cx, cy }));
};

// onPointerUp
() => { dragRef.current = null; };
¿POR QUÉ useRef PARA DRAG STATE?Porque no necesitamos re-renderizar cuando el drag empieza. Solo necesitamos guardar las posiciones iniciales y leerlas en onPointerMove. Si fuera useState, cada onPointerDown disparaba un re-render inútil. useRefes la forma idiomática de guardar "instance variables" en React.
EL TRUCO DEL setPointerCaptureSin pointer capture, si el usuario arrastra rápido y el cursor sale del frame, los onPointerMove dejan de llegar y el drag queda colgado. setPointerCapture hace que el target original siga recibiendo todos los events hasta el pointerup, incluso fuera del element. Drag fluido garantizado.

10 / Zoom: wheel + slider

Dos formas de hacer zoom en cada frame.

Slider HTML

Un <input type="range" min={1} max={4} step={0.01}>. Trivial. Tiene un <label htmlFor> visualmente oculto pero asociado para screen readers.

Wheel listener nativo (no React)

Acá hay un truco. React por default registra los listeners de onWheel como passive, lo que significa que e.preventDefault() no funciona y el scroll de la página se sigue moviendo. Para evitarlo, registramos el listener vía useEffect con { passive: false } directamente al DOM:

useEffect(() => {
  const el = frameRef.current;
  if (!el) return;
  const handler = (e) => {
    e.preventDefault();   // gracias al passive: false
    const delta = -e.deltaY * 0.002;
    const zoom = transform.zoom * (1 + delta);
    onTransformChange(clampedTransform(/* ... */, { zoom, cx, cy }));
  };
  el.addEventListener("wheel", handler, { passive: false });
  return () => el.removeEventListener("wheel", handler);
}, [/* deps */]);
BONUS: TRACKPAD PINCHEn Mac, la pinch en el trackpad genera wheel events con ctrlKey. El handler no chequea ctrlKey y trata cualquier deltaY como zoom, por lo que pinch en trackpad funciona gratis. Para multi-touch pinch en mobile haría falta lógica adicional con dos pointers simultáneos.

11 / Skeleton + fade: UX deliberada

Cuando se carga una imagen nueva, cada PresetCard muestra 400ms de skeleton (rayitas diagonales animadas) y después la imagen aparece con un fade de 320ms.

const [skeleton, setSkeleton] = useState(true);

useEffect(() => {
  setSkeleton(true);                                // reset al cargar una imagen nueva
  const id = setTimeout(() => setSkeleton(false), 400);
  return () => clearTimeout(id);
}, [image.src]);
¿POR QUÉ FORZAR UN DELAY ARTIFICIAL?El procesamiento es tan rápido que sin el skeleton parece que no hizo nada. La sensación percibida es importante: 400ms de skeleton + fade genera la sensación de "está procesando", lo que valida el trabajo a ojos del usuario. Es UX intencional, no un bug.

El fade usa CSS transition en opacity. El skeleton es un background: repeating-linear-gradient con animación @keyframes shimmer que desplaza el patrón a 0.8s/loop.

12 / El check de upscale (warning)

Cuando una imagen es más chica que el preset, no la bloqueamos, la dejamos descargable pero con un warning visual.

const coverScale = getCoverScale(imgW, imgH, preset.width, preset.height);
const upscalePct = Math.round((coverScale - 1) * 100);
const fits = coverScale <= 1;

Si coverScale > 1, la imagen necesita ser agrandada para cubrir el preset. Se ve pixelada. Mostramos:

  • Un badge magenta en la esquina del frame: ⚠ +28%.
  • El botón download cambia a magenta y dice ↓ GIMME (UPSCALED +28%).
  • Igual se puede descargar. El user decide.

13 / Export a WebP

La función central que convierte un transform en un Blob WebP:

export async function renderPresetToBlob(image, preset, transform) {
  const canvas = document.createElement("canvas");
  canvas.width = preset.width;        // canvas al tamaño exacto del preset
  canvas.height = preset.height;
  const ctx = canvas.getContext("2d");
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = "high"; // mejor remuestreo disponible

  const m = computeFrame(
    image.naturalWidth, image.naturalHeight,
    preset.width, preset.height,
    transform,
  );
  ctx.drawImage(image, m.tx, m.ty, m.renderedW, m.renderedH);

  return new Promise((resolve) =>
    canvas.toBlob((b) => resolve(b), "image/webp", 0.92),
  );
}

La clave: computeFrame() se llama con las dimensiones del canvas, no del preview. La misma función produce los valores correctos para cualquier escala, por eso lo que se ve chiquito en pantalla es exactamente lo que termina en el WebP.

El download

export function triggerDownload(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  URL.revokeObjectURL(url);   // liberar memoria
}
¿POR QUÉ Q=0.92?Es el sweet spot del WebP: 92% mantiene la imagen visualmente indistinguible del original pero ahorra ~30% de tamaño vs q=100. Por debajo de 80 ya empiezan a verse artefactos sutiles en fotos con bordes finos.

14 / ZIPs en el browser

Usamos JSZip (única dependencia que no viene con Next.js). Genera ZIPs en memoria sin tocar disco.

export async function buildZip(image, imageName, presets, transforms) {
  const zip = new JSZip();
  for (const preset of presets) {
    const blob = await renderPresetToBlob(
      image, preset,
      transforms[preset.id] ?? DEFAULT_TRANSFORM,
    );
    if (blob) zip.file(fileNameFor(imageName, preset), blob);
  }
  return zip.generateAsync({ type: "blob" });
}
¿POR QUÉ SECUENCIAL Y NO Promise.all?Cada toBlob()con canvas grande (2560×1440 por ejemplo) chupa RAM. Hacerlos en paralelo en mobile peta la página. Secuencial es ~1s para los 23 presets en una imagen mediana, aceptable. Y la UI muestra "ZIPPING…" para que se vea que está laburando.

Hay tres puntos de entrada a buildZip:

  • ZIP ALL (23): botón cyan al lado de SWAP, manda todos los presets.
  • DOWNLOAD SET: un botón por plataforma en su banda, solo manda los presets de esa plataforma.
  • Single .webp: el botón ↓ GIMME de cada card, no usa ZIP, hace un download directo.

15 / Brutalismo aplicado

Toda la estética sigue 7 reglas, listadas también en /ui-kit:

  1. Esquinas afiladas. Cero border-radius.
  2. Bordes negros de 3px en todo lo que tenga frontera.
  3. Hover hace thunk: translate(-2px, -2px) + box-shadow: 4px 4px 0 color. Sin blur.
  4. Copy en mayúscula, confrontacional. Punto final como signo.
  5. Paleta saturada (amarillo, magenta, cyan, rojo). Nunca pastel.
  6. Brand colors oficiales (con minimal darkening en YT y FB para WCAG).
  7. Warning no es blocker. Mostrá el costo, dejá decidir.

Tokens

Definidos en globals.css con CSS custom props:

:root {
  --paper: #ffffff;
  --ink: #000000;
  --yellow: #fff200;     /* acción primaria */
  --magenta: #ff0066;    /* warnings, upscale */
  --cyan: #00e0ff;       /* acción secundaria (ZIP ALL) */
  --red: #ff2200;
  --lime: #c4ff00;       /* reserva (no se usa) */

  --line-w: 3px;

  --font-display: "Space Grotesk", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, monospace;
}
¿POR QUÉ CSS MODULES Y NO TAILWIND?El brutalism no necesita un design system grande: tokens + Space Grotesk + 4 colores + 3 reglas alcanzan. CSS Modules da scoping por archivo y zero runtime cost. Tailwind sumaría complejidad innecesaria para este alcance.

16 / A11y: qué se hizo y por qué

Lighthouse a11y score: 100/100 en / y en /ui-kit. Lista de qué se cubrió:

CategoríaQué se hizo
Skip linkLink "Skip to content" al inicio del body, visible solo en focus. Apunta a #content que es el <main>.
Heading hierarchyUn solo <h1> por página. En home: "DROP. AN. IMAGE." (no-loaded) o el filename (loaded). En /ui-kit: "WEB BRUTALISM." Después h2 para plataformas y h3 para presets.
Landmarks<header>, <nav aria-label="Primary">, <main id="content">, <section aria-labelledby>, <footer>.
Dropzone keyboardrole="button" + tabIndex={0} + handler de Enter/Space.
Slider labels<label htmlFor> visualmente oculto (.srOnly) pero asociado.
ARIA labelsBotones con texto chiquito tienen aria-label descriptivo. Imagen del frame tiene alt "{name} preview for {label}".
:focus-visibleOutline cyan 3px en todo lo focusable. :focus sin :focus-visible queda sin outline para no joder a usuarios de mouse.
Reduced motion@media (prefers-reduced-motion: reduce) anula todas las animaciones y transitions.
Color contrastTodo el texto pasa WCAG AA (4.5:1 normal, 3:1 large). YT bajó de #ff0000 a #db0000 y FB de #1877f2 a #166fe5 para llegar al threshold.
TRADE-OFFA11y muchas veces se posterga en prototipos. En este caso se hizo desde el principio: vale la pena cuando la app ya tiene una estructura mínima estable, y suma mucho menos overhead que retrofitearla después.

17 / Decisiones y trade-offs

Client-side only

+ Privacidad real (no se sube nada).
+ Cero costo operativo.
+ Funciona offline después de la primera carga.
−Sin posibilidad de smart-crop con ML (requeriría server o WASM gordo).
−Imágenes muy grandes pueden saturar RAM mobile.

WebP q=92 fijo

+ Sweet spot calidad/tamaño.
−No le da control al usuario sobre el nivel de compresión.

Cover crop only (no contain, no stretch)

+ El output siempre cubre el frame exacto. No hay "padding" raro.
−Si la imagen no llega al ratio del preset, partes se pierden. Es por diseño.

Transform por preset (vs. uno global)

+ Permite enfocar la cara para Instagram y el horizonte para YouTube banner.
−Más state que mantener, pero está acotado al objeto transforms.

Skeleton + fade artificiales

+ Sensación percibida de procesamiento.
−Suma 400ms de espera real. En contextos low-latency podríamos darle un toggle.

ZIP secuencial

+ No revienta memoria en mobile.
−Algo más lento que en paralelo. Aceptable para 23 presets.

No backend

+ Deploy a static hosting (Vercel free, Netlify, GitHub Pages).
−No analytics ni telemetría sin meter algo aparte.

Brutalismo

+ Memorable, anti-template, fuerte identidad.
−Polariza. No es para todo público, pero ese era el punto.