Case study · 2026

expression.

Collaborative editorial editor

Professional layout lives in single-player desktop apps. But a newsroom is a choir. expression brings InDesign-grade rigor to the browser and adds what’s missing: each part of the spread goes to its owner, who edits only theirs —without seeing the rest, not even over the wire. From draft to print, with briefings, deadlines and history always in view.

expression

A newsroom isn’t one person

InDesign and Affinity are brilliant and deeply single-player: one file, one designer. Google Docs and Figma collaborate beautifully, but know nothing of baselines, gutters or spreads. In between sits the real work of a magazine —editors, writers and designers touching different parts of the same issue, against the clock— with no single tool to hold it.

Desktop-grade layout, in a browser tab

expression is a spread editor that runs in the browser with the ambition of a desktop tool: master pages, columns with gutters, a baseline grid, paragraph and character styles, rich text and images. On top, the nervous system of a newsroom: briefings, deadlines, editorial statuses and work split into nodes. Nothing to install.

The expression editor with an article laid out
Column and baseline guides, text and image frames, a floating tool island and inspector — in the browser.
  • Spreads with multi-page master pages
  • Columns, gutters and a baseline grid (6pt)
  • Rich text with paragraph and character styles
  • Multi-selection, groups and smart guides with snapping
  • Layer effects: blur, noise, blend modes
  • Node sharing with roles (view / comment / edit)
  • Editorial flow with statuses, briefings and deadlines
  • Vectorial PDF export and history with checkpoints

The trick: share without filtering

Sharing “just one part” is usually a white lie: the UI hides the rest, but the whole document travels anyway. Not in expression. When you hand off a node, the boundary doesn’t live in the UI —it lives in the database. A Postgres function builds a brand-new document containing only that node; the guest never receives a single byte of the rest.

Security isn’t painted, it’s projected

Reads and writes go through token-validated SECURITY DEFINER functions: they project only the node on read and merge only the node on write. Real granularity, not fake permissions.

-- el invitado solo recibe los frames de SU nodo; lo demás no viaja
v_doc := jsonb_build_object(
  'pages', (
    select jsonb_agg(jsonb_set(pg, '{frames}', (
      select jsonb_agg(fr)
      from jsonb_array_elements(pg->'frames') fr
      where fr->>'groupId' = g        -- ← aquí vive la frontera
    )))
    from jsonb_array_elements(d.doc->'pages') pg
  )
  -- + masters y estilos; nunca los otros nodos
);
The boundary, inside Postgres · resolve_share()

On the client, the same editor saves to three targets —owner, full document or a single node— by picking the right function. The whole difference fits in one line:

const fn = target.kind === "shared-group"
  ? "save_shared_group"       // fusiona SOLO ese nodo
  : "save_shared_document";   // documento entero
await supabase.rpc(fn, { p_token: token, p_doc: doc });
The client just picks the RPC · useDocSave.ts
A guest’s view of a shared node
What the guest sees: only their node. The rest of the spread doesn’t exist client-side.
Mobile node view
The same node on mobile: a single column, with an exact cut of overset text.

Permissions you can feel

Before handing off, the owner can “view as” any role and isolate a node: a spotlight lights that part and fades the rest of the canvas to black. The permission stops being a checkbox and becomes an experience —you see exactly what your collaborator will see.

Ultra-focus: isolating a node with a spotlight
Isolate node: the rest of the canvas darkens and blurs; “viewing as” the collaborator.

From draft to print

Each node is an assignment with its own briefing —angle, owner, topics, deadline— and its status. Statuses (Draft → Review → Approved → Published) lock editing when they should; the always-visible deadline timer shifts color as the hour tightens. The editor understands the craft, not just the boxes.

Node briefing in the inspector
A node’s briefing in the inspector: angle, owner, topics, ideas and due date.

Inches, not pixels

A spread isn’t measured in pixels. expression works in inches and points (a 6pt baseline) and derives all geometry —columns, margins, snapping— from pure functions. Moving a box snaps it to the column and the baseline. The detail that made it feel real was snapping to the midpoint between lines, too.

// pegar a columnas y a la línea base es lo obvio.
// el matiz: imantar también al PUNTO MEDIO entre líneas base.
const mids = baselines
  .slice(0, -1)
  .map((y, i) => (y + baselines[i + 1]) / 2);
const snapY = [...baselines, ...mids];
The detail that changed the feel · document.ts
800%
zoom with exact geometry
in inches and points, derived from pure functions

The PDF is the editor, not a screenshot

No rasterizing the canvas. A server route opens the print page itself with headless Chromium —forwarding your session— and waits for fonts and images before capturing. Out comes a vectorial PDF, identical to the screen, print-ready. The same code runs on your Mac and on Vercel.

// solo cambia de dónde sale Chromium
return process.env.VERCEL
  ? puppeteer.launch({ args: chromium.args, executablePath: await chromium.executablePath() })
  : puppeteer.launch({ executablePath: systemChrome() });
// después: reenviar cookies, esperar fonts + imágenes, page.pdf()
One codebase, two environments · api/pdf/[docId]/route.ts
Exported PDF of a real issue
A real PDF: justified text, automatic hyphenation and a folio — faithful to the editor, vectorial.

A brand that draws itself

The identity isn’t a static logo: it’s a single continuous-curve stroke —the “e”— that animates itself into being and morphs between figures of the craft: text, page, pen, pencil, cursor. It lives as data, not as an image, so the same gesture flows at any color and size.

Brand lockup: single-stroke mark + “expression”
The lockup: the “e” as a single continuous-curve stroke, next to the wordmark.
// no es un PNG: es un trazo abierto que morfa entre figuras
export const EXPRESSION = {
  name: "expression",
  d: "M398,560 C483,557 527,470 583,360 …",  // la "e", una curva
};
// texto · página · pluma · lápiz · cursor — un gesto, otra forma
The brand as data · logoShapes.ts
Generative brand showcase
The repertoire: the same single stroke, in motion, forming each figure.
Brand color palette
Ink, accent, identity (the “viewing as”), baseline guide and warm paper.

How it’s built

The whole document is plain JSON, so the editor was built local-first and the backend wired in at the end without rewriting a thing. Next.js 16 and React 19 up front; Zustand + zundo for state and history; TipTap/ProseMirror for rich text; Supabase (Postgres, Auth, Storage and RPC) behind; and Puppeteer for the PDF.

Framework
Next.js 16 · React 19
Estado
Zustand + zundo
Texto
TipTap / ProseMirror
Backend
Supabase · Postgres + RPC
PDF
Puppeteer + Chromium
Deploy
Vercel
  1. The model and the canvas

    Document as plain JSON + frames with drag/resize and snapping to columns and baseline.

  2. Print-grade typography

    Master pages, paragraph and character styles, rich text with TipTap.

  3. Groups and permissions as experience

    Multi-selection, groups, role simulation and isolating a node with a spotlight.

  4. A newsroom flow

    Statuses that lock editing, per-node briefings and always-visible deadlines.

  5. Backend and airtight nodes ⭐

    Auth, persistence, Storage and the projection + scoped merge in the database.

  6. Vectorial PDF and generative brand

    Export with headless Chromium and the morphing single-stroke identity.

Not a demo

expression was born as the in-house tool of my own magazine. It earned its place in production —real issues, laid out by a real team— before opening to the community as a SaaS, free for now. It’s not a prototype looking for a problem: it’s a problem I already had, solved.

Estado
En producción
Origen
Dogfooding → SaaS
Precio
Gratis (por ahora)
Dominio
expression.ululando.com

Shall we build something together?