# ZUI Implementation Guide

A complete reference for building a Zoomable User Interface — an infinite canvas where content lives at spatial positions and users navigate by zooming in and out. Based on Jef Raskin's vision from *The Humane Interface*.

**Live demo:** [The Humane Interface — A ZUI Experience](https://tangen.ca/x/humane-interface/)

---

## Architecture Overview

The ZUI is a single HTML file with three layers:

1. **Data tree** — nested JSON defining content hierarchy
2. **Layout engine** — positions cards on an infinite canvas using recursive math
3. **Camera system** — CSS transforms to pan/zoom the canvas into view

Everything renders at native canvas coordinates. The camera scales it all at once.

---

## 1. HTML Structure

```html
<!-- Fixed chrome -->
<nav id="topnav">
  <div class="nav-left">
    <!-- Breadcrumb ticker (double-buffer for animation) -->
    <span id="bc-wrap">
      <span class="bc-bar" id="bc-a"><!-- active bar --></span>
      <span class="bc-bar" id="bc-b" style="opacity:0"><!-- standby bar --></span>
    </span>
  </div>
  <div class="nav-right"><!-- links --></div>
</nav>

<!-- The infinite canvas -->
<div id="vp">          <!-- viewport: clips the visible region -->
  <div id="cv"></div>   <!-- canvas: holds all cards, gets transformed -->
</div>

<footer id="footer"><!-- copyright, links --></footer>

<!-- Navigation affordances -->
<div id="edge"></div>   <!-- left-edge hint showing children exist -->
<div id="bk"></div>     <!-- right-edge hint for going back -->
<div id="depth"></div>  <!-- dot indicator showing zoom depth -->
```

### Key principle
The viewport (`#vp`) is `overflow:hidden` and fills the screen between nav and footer. The canvas (`#cv`) is `position:absolute` with `width:0;height:0` — it exists only as a transform origin. All cards are absolutely positioned children of `#cv`, rendered at their native canvas coordinates. The camera applies a single CSS `transform: translate(tx, ty) scale(z)` to `#cv` to bring the right region into view.

---

## 2. CSS Variables & Theming

```css
:root {
  --s: 300ms;                              /* standard transition duration */
  --e: cubic-bezier(0.2, 0, 0, 1);        /* standard easing */
  --ice: #3b9ece;                          /* accent color */
  --ir: 59, 158, 206;                      /* accent as RGB triplet for rgba() */
  --tx: #0a0c10;                           /* text color */
  --mu: #6b7280;                           /* muted/secondary text */
  --bg: #f8fafd;                           /* background */
  --mo: 'JetBrains Mono', monospace;       /* monospace font */
  --sa: 'Inter', -apple-system, sans-serif; /* sans-serif font */
}
```

All colors flow from these variables. To re-theme, change the values here.

---

## 3. Data Tree

Content is a nested JavaScript object. Each node has:

```javascript
const D = {
  id: 'root',
  title: 'Your Site Title',
  desc: 'Subtitle or tagline',
  children: [
    {
      id: '01',
      title: 'Section One',
      desc: 'Brief description shown on the card',
      children: [
        { id: '01.1', title: 'Subsection', desc: '...' }
      ]
    }
  ]
};
```

Rich content is stored in a separate map keyed by node ID:

```javascript
const RC = {
  'root': {
    tag: 'Optional category label',
    lead: 'Opening paragraph — slightly larger font',
    body: 'Main body text',
    body2: 'Second body paragraph',
    img: '1015',          // hero image ID
    img2: '883',          // inline image ID
    cap: 'Image caption',
    q: 'A blockquote',
    qa: 'Quote attribution'
  }
};
```

Images use an ID-based system. Replace with your own asset pipeline:

```javascript
const IMG = {
  img800: function(id) { return 'assets/' + id + '-800x400.jpg'; },
  img600: function(id) { return 'assets/' + id + '-600x300.jpg'; }
};
```

---

## 4. Layout Algorithm

Cards are positioned recursively. The root card is placed at fixed coordinates. Children are positioned to the LEFT of their parent at 25% scale.

```javascript
const L = {
  rx: 800,   // root x position (canvas coords)
  ry: 60,    // root y position
  rw: 600,   // root width
  rh: 420,   // root height
  sw: 0.25,  // child scale factor (children are 25% of parent width)
  gx: 0.02,  // horizontal gap between child and parent (fraction of parent width)
  car: 0.38  // child card aspect ratio (height / width)
};

function layout(node, x, y, w, h, level) {
  node.x = x; node.y = y; node.w = w; node.h = h; node.lv = level;
  if (!node.children) return;

  const childWidth = w * L.sw;
  const gapX = w * L.gx;
  const childX = x - gapX - childWidth;       // children go LEFT
  const childHeight = childWidth * L.car;
  const gapY = childHeight * 0.08;
  const startY = y;                            // top-aligned with parent

  node.children.forEach((child, i) => {
    layout(child, childX, startY + i * (childHeight + gapY), childWidth, childHeight, level + 1);
  });
}

layout(D, L.rx, L.ry, L.rw, L.rh, 0);
```

### Why left?
Children appear as a navigation column to the left of the focused card. This matches the reading direction — you see navigation options, then content. When you zoom into a child, its own children appear further left, creating infinite depth.

---

## 5. Card Rendering

Each card is a `<div class="nd">` absolutely positioned on the canvas:

```javascript
function renderCard(node) {
  const el = document.createElement('div');
  el.className = 'nd';
  el.style.cssText = `
    left:${node.x}px; top:${node.y}px;
    width:${node.w}px; height:${node.h}px;
    border-radius:${node.w * 0.014}px;
    border:${Math.max(0.15, node.w * 0.002)}px solid rgba(var(--ir), 0.08)
  `;

  // All sizes are proportional to card width
  const titleSize = node.w * 0.09;
  const bodySize = node.w * 0.0155;
  const heroHeight = node.w * 0.25;
  const padding = node.w * 0.04;

  el.innerHTML = `
    <div class="nd-in">
      <img class="nd-hero" src="${getImage(node)}" style="height:${heroHeight}px">
      <div class="nd-ct" style="padding:${padding}px">
        <div class="nd-t" style="font-size:${titleSize}px">${node.title}</div>
        <!-- tag, lead, body, blockquote, etc. -->
      </div>
    </div>
  `;

  node.el = el;
  canvas.appendChild(el);
}
```

### Proportional sizing
Every font size, padding, and margin is a fraction of the card's width (`node.w * factor`). This means cards look correct at any zoom level — a card that's 600px wide at zoom 1.0 looks identical to one that's 150px wide at zoom 0.25.

### Child cards: compact mode
Non-focused cards hide body content via CSS, showing only hero image and title:

```css
.nd:not(.focused) .nd-id,
.nd:not(.focused) .nd-tag,
.nd:not(.focused) .nd-lead,
.nd:not(.focused) .nd-body,
.nd:not(.focused) .nd-q,
.nd:not(.focused) .nd-img2,
.nd:not(.focused) .nd-cap { display: none }

.nd:not(.focused) .nd-ct {
  padding-top: 3px !important;
  padding-bottom: 0 !important;
}
```

---

## 6. Camera System

The camera computes what zoom level and position will frame a given card in the viewport:

```javascript
function nodeCam(node) {
  const vw = viewport.clientWidth;
  const vh = viewport.clientHeight;

  // Zoom to fit card width in 65% of viewport (leaving room for left nav)
  const z = (vw * 0.65) / node.w;

  // Center the card horizontally in the RIGHT 65% of viewport
  const fx = node.x + node.w / 2 + (vw * 0.175) / z;
  const fy = node.y;

  return { x: fx, y: fy, z: z };
}
```

Apply it with a single CSS transform:

```javascript
function applyCam(animate) {
  const tx = vw / 2 - cam.x * cam.z;
  const ty = vh / 2 - cam.y * cam.z;

  if (animate) {
    canvas.style.transition = 'transform 450ms cubic-bezier(0.4, 0, 0, 1)';
  } else {
    canvas.style.transition = 'none';
  }

  canvas.style.transform = `translate(${tx}px, ${ty}px) scale(${cam.z})`;
}
```

### Why one transform?
Instead of moving individual cards, we transform the entire canvas. This means:
- All cards maintain their spatial relationships
- The browser's compositor handles the animation (GPU-accelerated)
- Adding thousands of cards costs nothing at render time
- Zoom is literally just changing the scale value

---

## 7. Navigation

Navigation is a stack (`hist[]`). Each entry is a node reference.

```javascript
let hist = [rootNode]; // start at root

function zoomTo(node) {
  hist.push(node);
  cam = nodeCam(node);
  applyCam(true);     // animate the camera
  updateVisibility();  // show/hide cards
  updateUI();          // update breadcrumbs, depth dots
}

function goBack() {
  hist.pop();
  cam = nodeCam(hist[hist.length - 1]);
  applyCam(true);
  updateVisibility();
  updateUI();
}
```

### Keyboard support
```
← or Escape    zoom out (go back)
→ or Enter     zoom into selected child
↑ / ↓          select previous/next sibling
```

---

## 8. Visibility System

Only three categories of cards are visible:
1. **Focused card** — the current node, scrollable, full content
2. **Children** — left nav cards, compact mode (hero + title only)
3. **Fading** — the previously focused card, briefly visible during zoom transition

Everything else is `opacity:0; pointer-events:none`.

```javascript
function updateVisibility() {
  const focused = hist[hist.length - 1];
  const children = new Set(focused.children || []);

  allNodes.forEach(node => {
    if (node === focused) {
      node.el.classList.add('focused');
      node.el.style.opacity = '1';
      node.el.style.pointerEvents = 'auto';
      // Enable scrolling, expand height to viewport
    } else if (children.has(node)) {
      node.el.style.opacity = '1';
      node.el.style.cursor = 'zoom-in';
      // Compact mode via CSS :not(.focused)
    } else {
      node.el.style.opacity = '0';
      node.el.style.pointerEvents = 'none';
    }
  });
}
```

---

## 9. Breadcrumb Ticker

Uses a double-buffer crossfade with anime.js. Two absolutely positioned bars alternate — when navigating, the old bar slides out while the new bar slides in.

```html
<span id="bc-wrap">
  <span class="bc-bar" id="bc-a"><!-- current --></span>
  <span class="bc-bar" id="bc-b" style="opacity:0"><!-- standby --></span>
</span>
```

```javascript
// Track which bar is active
updNav._active = 'a';
updNav._prev = -1;

function updNav() {
  const depth = hist.length - 1;
  const deeper = depth > updNav._prev;
  updNav._prev = depth;

  // Build breadcrumb: current title ● ancestor ● ancestor...
  let html = '<span class="bc-dot"></span>'
           + '<span class="bc-title">' + currentTitle + '</span>';
  ancestors.forEach(a => {
    html += '<span class="bc-dot"></span>'
          + '<span class="bc-anc">' + a.title + '</span>';
  });

  // Swap buffers
  const old = document.getElementById('bc-' + updNav._active);
  const next = document.getElementById('bc-' + (updNav._active === 'a' ? 'b' : 'a'));
  updNav._active = updNav._active === 'a' ? 'b' : 'a';

  next.innerHTML = html;

  // Crossfade with directional slide
  const slideOut = deeper ? 40 : -40;
  anime.remove(old); anime.remove(next);
  anime({ targets: old,  translateX: [0, slideOut],  opacity: [1, 0], easing: 'easeOutCubic', duration: 280 });
  anime({ targets: next, translateX: [-slideOut, 0], opacity: [0, 1], easing: 'easeOutCubic', duration: 280 });
}
```

Blue dots (`●`) separate breadcrumb segments. Ancestors are clickable to jump back.

---

## 10. Visual Affordances

### Edge hints
A subtle strip on the left edge appears when the focused card has children. Clicking it zooms into the first child.

### Back hint
A strip on the right edge appears at depth > 0. Clicking it zooms out.

### Depth dots
Bottom-right corner shows 4 dots indicating current zoom depth (0–3).

### Dot grid background
The viewport has a faint dot grid (`radial-gradient`) that gives spatial texture to the canvas.

### Vignette
An `inset box-shadow` on the viewport creates a subtle vignette — content fades toward the edges, drawing focus to the center.

---

## 11. Adapting for Your Content

### Step 1: Define your tree
Replace the `D` object with your content hierarchy. Any depth is supported.

### Step 2: Add rich content
Populate the `RC` map with body text, images, quotes for each node ID.

### Step 3: Set up images
Replace the `IMG` helper with your asset pipeline (CDN URLs, local paths, etc).

### Step 4: Adjust layout constants
Tune `L.rw` (root card width), `L.sw` (child scale), `L.car` (card aspect ratio) to fit your content density.

### Step 5: Re-theme
Change the CSS variables in `:root` to match your brand colors and typography.

---

## 12. Design Principles

These principles guided every decision:

1. **The card IS the content at every zoom level.** No overlays, no separate views. What you see zoomed out is what you see zoomed in, just at different detail levels.

2. **Proportional sizing.** Every measurement is a fraction of card width. Cards look correct at any scale.

3. **Spatial stability.** Content lives at fixed canvas coordinates. Users build a mental map over time — "the section about cognition is down and to the left."

4. **Progressive disclosure.** Zoomed out, you see structure (hero + title). Zoomed in, you see substance (full article). The interface parcels information at the scale of your focus.

5. **One interaction model.** Click to zoom in. Escape to zoom out. Arrow keys to browse siblings. Nothing else.

---

## Dependencies

- **anime.js v3** — breadcrumb ticker animation only. Can be removed if you use CSS transitions instead.
- **Inter + JetBrains Mono** — Google Fonts. Swap for any fonts.
- No build step, no framework. Single HTML file.

---

## Browser Support

- Modern browsers (Chrome, Safari, Firefox, Edge)
- `backdrop-filter` for frosted nav/footer (graceful degradation)
- `prefers-reduced-motion` respected — all animations disabled
- Touch support: pinch-to-zoom not implemented (would need Hammer.js or similar)

---

## License

MIT. Use it however you want.
