# Recipe: Review and approve

A review room over one board for the people who decide: a grid with each asset's state, a Start review button that walks through what still needs review, and a panel to approve or request changes. A note becomes a comment on the asset; quick replies and keyboard keys make a long review fast. Decisions and comments are written to Playbook as the signed-in visitor. Light and dark themes follow the visitor's system, with a toggle.

## When to use it[​](#when-to-use-it "Direct link to When to use it")

* A team or a client approves work: a campaign, social posts, a photo selection, product shots.
* The app must be `members`: only an app for the board's people may write. Invite a client without a Playbook account as a guest to the board; they sign in once. A public or password app can show the same grid but cannot record a decision.

## How the board drives it[​](#how-the-board-drives-it "Direct link to How the board drives it")

* **A select field** holds each asset's state. Create it once with `create_custom_field`: a select named `Review` with the options `Needs review`, `Approved` and `Changes requested`. An asset with no value counts as needing review.
* **An asset's title and description** are the card's title and the caption in the panel.
* **Comments** are read when an asset opens, as the visitor sees them in Playbook, team-only comments marked.
* **Approve** sets the field to `Approved`; a note, when written, is added as a comment. **Request changes** asks for a note, then sets `Changes requested` and adds the note as a comment.

Who may decide is Playbook's choice, not the page's: someone who can only view the board sees everything, and their decision answers that their account may look but not change.

## The files[​](#the-files "Direct link to The files")

`index.html`:

```
<!doctype html>

<html lang="en">

<head>

<meta charset="UTF-8" />

<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<title>Review</title>

<script>

  // The visitor's theme, set before the page paints.

  try { const theme = localStorage.getItem(`theme:${location.pathname}`); if (theme) document.documentElement.dataset.theme = theme } catch {}

</script>

<link rel="icon" href="data:," />

<link rel="preconnect" href="https://fonts.googleapis.com" />

<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600&display=swap" />

<link rel="stylesheet" href="styles.css" />

</head>

<body>

<header class="top">

  <p class="stats" id="stats"></p>

  <button type="button" class="theme" id="theme" aria-label="Switch between light and dark"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M12 4a8 8 0 0 1 0 16z" fill="currentColor" /></svg></button>

</header>



<section class="hero">

  <p class="eyebrow">Review</p>

  <h1 id="title">Loading…</h1>

  <p class="subtitle" id="subtitle"></p>

  <div class="controls">

    <button type="button" class="solid" id="start" hidden></button>

    <nav class="filters" id="filters" aria-label="Show"></nav>

  </div>

</section>



<main class="grid" id="grid" aria-live="polite"></main>

<p class="notice" id="notice" hidden>The board did not load. Reload the page to try again.</p>



<div class="review" id="review" hidden>

  <figure class="stage"><img id="r-image" alt="" /></figure>

  <aside class="panel" aria-labelledby="r-title">

    <div class="panel-top">

      <button type="button" class="back" id="r-close">← All posts</button>

      <div class="queue">

        <button type="button" id="r-prev" aria-label="Previous">‹</button>

        <span id="r-position"></span>

        <button type="button" id="r-next" aria-label="Next">›</button>

      </div>

    </div>

    <p class="kicker" id="r-kicker"></p>

    <h2 id="r-title"></h2>

    <p class="state" id="r-state"></p>

    <div class="actions">

      <button type="button" class="solid approve" id="r-approve">Approve</button>

      <button type="button" class="ghost changes" id="r-changes">Request changes</button>

    </div>

    <p class="keys">A approve · R request changes · ← → move</p>

    <h3 id="r-caption-head">Caption</h3>

    <p class="caption" id="r-caption"></p>

    <h3>Feedback <span id="r-count"></span></h3>

    <ol class="thread" id="r-thread"></ol>

    <form class="composer-wrap" id="r-composer">

      <div class="chips" id="r-chips" aria-label="Quick feedback"></div>

      <div class="composer">

        <textarea id="r-draft" rows="2" placeholder="Leave feedback for the team…" aria-label="Your feedback"></textarea>

        <button type="submit" class="solid" id="r-send">Send</button>

      </div>

      <p class="requesting" id="r-requesting" hidden>Say what should change, then send. <button type="button" id="r-cancel">Cancel</button></p>

      <p class="error" id="r-error" role="alert"></p>

    </form>

  </aside>

</div>



<p class="toast" id="toast" role="status" hidden></p>



<script type="module" src="app.js"></script>

</body>

</html>
```

`styles.css`:

```
/* Dark by default, light when the system asks for it, and either one when the

   visitor picks it (data-theme on <html>, set by the toggle). */

:root {

  color-scheme: dark;

  --bg: #050507;

  --ink: #f4f3f7;

  --ink-2: #e4e2ea;

  --soft: #c9c7d2;

  --muted: #9a98a6;

  --line: rgba(255, 255, 255, 0.1);

  --glass: rgba(14, 13, 19, 0.78);

  --sheet: rgb(13 12 18 / 0.98);

  --overlay: rgba(5, 5, 7, 0.94);

  --field: rgba(0, 0, 0, 0.4);

  --glow: rgba(200, 107, 255, 0.16);

  --shadow: rgba(0, 0, 0, 0.6);

  --solid: #ffffff;

  --on-solid: #0b0b10;

  --accent: linear-gradient(90deg, #ff7a9a, #c86bff 45%, #6aa8ff);

  --focus: rgba(200, 107, 255, 0.75);

  --waiting: #c9c7d2;

  --changes: #ff6b7a;

  --on-changes: #2a0509;

  --approved: #8ef0b5;

  --on-approved: #06260f;

  --internal: #ffb547;

  --panel: 440px;

}

@media (prefers-color-scheme: light) {

  :root:not([data-theme='dark']) {

    color-scheme: light;

    --bg: #f6f5f9; --ink: #15141a; --ink-2: #2b2a33; --soft: #4a4856; --muted: #77758a; --line: rgba(20, 18, 30, 0.1);

    --glass: rgba(255, 255, 255, 0.82); --sheet: rgb(255 255 255 / 0.98); --overlay: rgba(246, 245, 249, 0.94);

    --field: rgba(20, 18, 30, 0.04); --glow: rgba(200, 107, 255, 0.12); --shadow: rgba(20, 18, 30, 0.18); --solid: #15141a; --on-solid: #ffffff;

    --focus: rgba(140, 60, 220, 0.6); --waiting: #6b6880; --changes: #d63a4c; --on-changes: #ffffff;

    --approved: #1d9a5b; --on-approved: #ffffff; --internal: #b7790f;

  }

}

:root[data-theme='light'] {

  color-scheme: light;

  --bg: #f6f5f9; --ink: #15141a; --ink-2: #2b2a33; --soft: #4a4856; --muted: #77758a; --line: rgba(20, 18, 30, 0.1);

  --glass: rgba(255, 255, 255, 0.82); --sheet: rgb(255 255 255 / 0.98); --overlay: rgba(246, 245, 249, 0.94);

  --field: rgba(20, 18, 30, 0.04); --glow: rgba(200, 107, 255, 0.12); --shadow: rgba(20, 18, 30, 0.18); --solid: #15141a; --on-solid: #ffffff;

  --focus: rgba(140, 60, 220, 0.6); --waiting: #6b6880; --changes: #d63a4c; --on-changes: #ffffff;

  --approved: #1d9a5b; --on-approved: #ffffff; --internal: #b7790f;

}



* { box-sizing: border-box; }

[hidden] { display: none !important; }

body {

  margin: 0;

  min-height: 100vh;

  background: radial-gradient(900px 500px at 15% -10%, var(--glow), transparent 70%), var(--bg);

  color: var(--ink);

  font: 15px/1.45 'Inter Tight', system-ui, sans-serif;

  -webkit-font-smoothing: antialiased;

}

body.reviewing { overflow: hidden; }

button, textarea { font: inherit; color: inherit; }

button { cursor: pointer; }

:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }



.solid, .ghost { padding: 10px 18px; border-radius: 999px; font-weight: 600; white-space: nowrap; }

.solid { border: 0; background: var(--solid); color: var(--on-solid); }

.ghost { border: 1px solid var(--line); background: var(--glass); backdrop-filter: blur(14px); }

.ghost[aria-pressed='true'] { border-color: var(--solid); background: var(--solid); color: var(--on-solid); }

.theme { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: var(--glass); color: var(--soft); }

.theme svg { width: 16px; height: 16px; }

.solid:disabled, .ghost:disabled { opacity: 0.5; cursor: default; }



.dot { display: inline-block; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--waiting); }

.dot.changes { background: var(--changes); }

.dot.approved { background: var(--approved); }



/* Home */

.top { display: flex; justify-content: flex-end; align-items: center; gap: 16px; padding: 22px 28px; }

.stats { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px 16px; margin: 0; color: var(--muted); font-size: 14px; }

.stats span { display: inline-flex; align-items: center; gap: 6px; }

.stats b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }



.hero { padding: 56px 28px 28px; }

.eyebrow {

  margin: 0 0 12px; font-size: 13px; letter-spacing: 0.14em; text-transform: uppercase;

  background: var(--accent); -webkit-background-clip: text; background-clip: text; color: transparent;

}

h1 { max-width: 14ch; margin: 0; font-size: clamp(40px, 6.5vw, 76px); font-weight: 600; line-height: 0.98; letter-spacing: -0.035em; }

.subtitle { max-width: 56ch; margin: 14px 0 0; color: var(--soft); }

.subtitle:empty { display: none; }

.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 28px; }

.filters { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }

.filters .ghost { padding: 7px 14px; font-size: 13px; font-weight: 500; }



.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; padding: 0 28px 64px; }

.tile { position: relative; aspect-ratio: 4 / 5; overflow: hidden; border-radius: 14px; color: #fff; text-decoration: none; }

.tile img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }

.tile:hover img { transform: scale(1.04); }

.tile::after { content: ''; position: absolute; inset: 55% 0 0; background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); }

.tile-title { position: absolute; z-index: 1; left: 12px; right: 12px; bottom: 10px; font-weight: 500; font-size: 14px; line-height: 1.25; }

.badge {

  position: absolute; z-index: 1; top: 10px; left: 10px; display: flex; align-items: center; gap: 6px;

  padding: 3px 9px; border-radius: 999px; background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(8px); font-size: 11px;

}

.empty, .notice { color: var(--muted); }

.notice { padding: 0 28px; }



/* Review: the asset on the stage, the decision in a glass panel. */

.review { position: fixed; inset: 0; z-index: 10; background: var(--overlay); backdrop-filter: blur(10px); animation: fade 0.3s ease; }

.stage { position: absolute; top: 0; bottom: 0; left: 0; right: calc(var(--panel) + 32px); display: flex; align-items: center; justify-content: center; margin: 0; padding: 40px; }

.stage img { display: block; max-width: 100%; max-height: 100%; border-radius: 14px; box-shadow: 0 30px 80px var(--shadow); }

.panel {

  position: absolute; top: 16px; right: 16px; bottom: 16px; width: min(var(--panel), 100% - 32px);

  display: flex; flex-direction: column; overflow-y: auto; padding: 20px 22px 0;

  border: 1px solid var(--line); border-radius: 22px; background: var(--glass); backdrop-filter: blur(22px);

  animation: slide 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);

}

.panel > * { flex-shrink: 0; }

@keyframes fade { from { opacity: 0; } }

@keyframes slide { from { opacity: 0; transform: translateX(24px); } }



.panel-top { display: flex; justify-content: space-between; align-items: center; }

.back { padding: 0; border: 0; background: none; color: var(--muted); }

.back:hover { color: var(--ink); }

.queue { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }

.queue button { width: 28px; height: 28px; border: 1px solid var(--line); border-radius: 50%; background: none; }

.kicker { margin: 16px 0 4px; color: var(--muted); font-size: 13px; }

.panel h2 { margin: 0; font-size: 24px; font-weight: 600; line-height: 1.1; letter-spacing: -0.02em; overflow-wrap: anywhere; }

.state { margin: 10px 0 0; font-size: 13px; }

.state span { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border: 1px solid currentColor; border-radius: 999px; color: var(--waiting); }

.state span::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }

.state .changes { color: var(--changes); }

.state .approved { color: var(--approved); }

.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }

.actions .approve { background: var(--approved); color: var(--on-approved); }

.actions .changes { border-color: rgba(255, 107, 122, 0.6); color: var(--changes); }

.keys { margin: 8px 0 0; color: var(--muted); font-size: 12px; }

.panel h3 { margin: 22px 0 8px; color: var(--muted); font-size: 12px; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; }

.caption { margin: 0; color: var(--ink-2); white-space: pre-line; }



.thread { margin: 0; padding: 0; list-style: none; }

.thread .empty { padding: 4px 0; }

.comment { display: grid; grid-template-columns: 30px 1fr; gap: 2px 10px; padding: 8px 0; }

.avatar { grid-row: span 2; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; color: #0b0b10; font-size: 11px; font-weight: 600; }

.comment header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; font-size: 13px; }

.comment time { color: var(--muted); }

.tag { padding: 1px 7px; border: 1px solid currentColor; border-radius: 999px; color: var(--internal); font-size: 11px; }

.comment p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }

.comment ol { grid-column: 2; margin: 4px 0 0; padding: 0 0 0 10px; border-left: 1px solid var(--line); list-style: none; }



.composer-wrap { position: sticky; bottom: 0; margin: 16px -22px 0; padding: 12px 22px 18px; border-top: 1px solid var(--line); background: var(--sheet); }

.chips { display: flex; flex-wrap: wrap; gap: 6px; }

.chips button { padding: 5px 11px; border: 1px solid var(--line); border-radius: 999px; background: none; color: var(--soft); font-size: 12px; }

.chips button:hover { border-color: var(--focus); color: var(--ink); }

.composer { display: flex; align-items: flex-end; gap: 8px; margin-top: 10px; }

.composer textarea { flex: 1; min-width: 0; padding: 10px 14px; resize: none; border: 1px solid var(--line); border-radius: 16px; background: var(--field); outline: none; }

.composer textarea:focus { border-color: var(--focus); }

.is-requesting textarea { border-color: rgba(255, 107, 122, 0.8); }

.is-requesting .solid { background: var(--changes); color: var(--on-changes); }

.requesting { margin: 8px 0 0; color: var(--changes); font-size: 12px; }

.requesting button { padding: 0; border: 0; background: none; color: var(--muted); font-size: 12px; text-decoration: underline; }

.error { margin: 8px 0 0; color: var(--changes); font-size: 13px; }

.error:empty { display: none; }



.toast {

  position: fixed; z-index: 20; top: 22px; left: 50%; margin: 0; padding: 9px 16px; transform: translateX(-50%);

  border-radius: 999px; background: var(--solid); color: var(--on-solid); font-size: 14px; font-weight: 500; animation: fade 0.3s;

}



@media (max-width: 760px) {

  .top { padding: 16px; }

  .stats { font-size: 12px; gap: 4px 10px; }

  .hero { padding: 28px 16px 20px; }

  .controls .solid { flex: 1; }

  .filters { width: calc(100% + 32px); margin: 0 -16px; padding: 0 16px; }

  .grid { grid-template-columns: repeat(2, 1fr); gap: 6px; padding: 0 16px 48px; }

  .tile { border-radius: 10px; }

  .stage { right: 0; bottom: 58%; padding: 16px; }

  .panel { top: auto; left: 8px; right: 8px; bottom: 8px; width: auto; height: calc(58% - 8px); padding: 16px 16px 0; }

  .composer-wrap { margin: 12px -16px 0; padding: 12px 16px 14px; }

}



@media (prefers-reduced-motion: reduce) {

  *, *::before, *::after { animation: none !important; transition: none !important; }

}
```

`app.js`:

```
// A review room over one board, open to the board's people (access `members`).

// A select field holds each asset's review state. A decision is written to that

// field and feedback becomes a comment, both as the signed-in visitor.



const FIELD = 'Review'

const APPROVED = 'Approved'

const CHANGES = 'Changes requested'

const CHIPS = ['Love it', 'Brighter, please', 'Tighter crop', 'Try another option', 'Needs the logo']

const LABELS = { waiting: 'Needs review', changes: 'Changes requested', approved: 'Approved' }



const $ = (id) => document.getElementById(id)

const plain = (html) => new DOMParser().parseFromString(html || '', 'text/html').body.textContent.trim()

const el = (tag, className, text) => {

  const node = document.createElement(tag)

  if (className) node.className = className

  if (text) node.textContent = text

  return node

}

const dot = (state) => el('i', `dot ${state}`)



// Reads and changes on the app's own address. Busy or cold reads (429, 5xx)

// are retried twice; a sign-in that ended (401) starts again with a reload.

async function call(path, { method = 'GET', body, tries = 3 } = {}) {

  const response = await fetch(`playbook/api/${path}`, {

    method,

    headers: body ? { 'Content-Type': 'application/json' } : {},

    body: body && JSON.stringify(body),

  }).catch(() => null)

  const status = response?.status ?? 0

  if (status === 401) location.reload()

  if (method === 'GET' && tries > 1 && (status === 0 || status === 429 || status >= 500)) {

    const wait = Number(response?.headers.get('Retry-After')) || 2

    await new Promise((resolve) => setTimeout(resolve, wait * 1000))

    return call(path, { method, body, tries: tries - 1 })

  }

  const answer = response ? await response.json().catch(() => ({})) : {}

  if (response?.ok) return answer

  if (status === 403) throw new Error('Your Playbook account may look at this board, not change it.')

  throw new Error(answer.errors?.[0]?.message || 'That did not go through. Try again.')

}



let items = []

let filter = 'all'

let open = -1

let requesting = false

const threads = new Map()



function stateOf(asset) {

  const value = asset.fields?.[FIELD]

  if (value === APPROVED) return 'approved'

  if (value === CHANGES) return 'changes'

  return 'waiting'

}



function thumb(asset, width) {

  const thumbs = [...asset.thumbnails].sort((a, b) => a.width - b.width)

  return (thumbs.find((t) => t.width >= width) || thumbs.at(-1)).url

}



const day = (iso) => new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })

const count = (state) => items.filter((asset) => stateOf(asset) === state).length



function toast(text) {

  $('toast').textContent = text

  $('toast').hidden = false

  clearTimeout(toast.timer)

  toast.timer = setTimeout(() => ($('toast').hidden = true), 2400)

}



function renderHome() {

  $('stats').replaceChildren(

    ...Object.entries(LABELS).map(([state, label]) => {

      const stat = el('span')

      stat.append(dot(state), el('b', '', String(count(state))), ` ${label.toLowerCase()}`)

      return stat

    })

  )

  const waiting = count('waiting')

  $('start').textContent = waiting ? `Start review · ${waiting}` : 'All reviewed'

  $('start').disabled = !waiting

  $('start').hidden = false



  $('filters').replaceChildren(

    ...[['all', 'All'], ...Object.entries(LABELS)].map(([key, label]) => {

      const button = el('button', 'ghost', label)

      button.type = 'button'

      button.setAttribute('aria-pressed', String(filter === key))

      button.addEventListener('click', () => {

        filter = key

        renderHome()

      })

      return button

    })

  )



  const shown = items.filter((asset) => filter === 'all' || stateOf(asset) === filter)

  $('grid').replaceChildren(

    ...(shown.length

      ? shown.map((asset) => {

          const tile = el('a', 'tile')

          tile.href = `#/${asset.token}`

          tile.style.backgroundColor = asset.average_color || ''

          const image = el('img')

          image.src = thumb(asset, 480)

          image.alt = asset.title || ''

          image.loading = 'lazy'

          const badge = el('span', 'badge')

          badge.append(dot(stateOf(asset)), LABELS[stateOf(asset)])

          tile.append(image, badge, el('span', 'tile-title', asset.title))

          return tile

        })

      : [el('p', 'empty', 'Nothing here.')])

  )

}



function avatar(name) {

  const hue = [...(name || '?')].reduce((sum, ch) => sum + ch.charCodeAt(0), 0) % 360

  const node = el('span', 'avatar', (name || '?').split(/\s+/).map((part) => part[0]).join('').slice(0, 2).toUpperCase())

  node.style.background = `hsl(${hue} 70% 72%)`

  return node

}



function comment(item) {

  const node = el('li', 'comment')

  const head = el('header')

  head.append(el('b', '', item.user_name || 'Someone'), el('time', '', day(item.created_at)))

  if (item.internal) head.append(el('span', 'tag', 'Team only'))

  node.append(avatar(item.user_name), head, el('p', '', plain(item.description)))

  if (item.replies?.length) {

    const replies = el('ol')

    replies.append(...item.replies.map(comment))

    node.append(replies)

  }

  return node

}



function renderThread(asset) {

  const thread = threads.get(asset.token)

  $('r-count').textContent = asset.comment_count ? `· ${asset.comment_count}` : ''

  if (!thread) $('r-thread').replaceChildren(el('li', 'empty', 'Loading…'))

  else if (!thread.length) $('r-thread').replaceChildren(el('li', 'empty', 'No feedback yet.'))

  else $('r-thread').replaceChildren(...thread.map(comment))

}



// Comments are read when an asset is opened, never for the whole grid.

async function loadThread(asset) {

  if (!threads.has(asset.token)) {

    const { data } = asset.comment_count ? await call(`assets/${asset.token}/comments?per_page=100`) : { data: [] }

    threads.set(asset.token, data)

  }

  if (items[open] === asset) renderThread(asset)

}



function setRequesting(on) {

  requesting = on

  $('r-composer').classList.toggle('is-requesting', on)

  $('r-requesting').hidden = !on

  $('r-send').textContent = on ? 'Request changes' : 'Send'

  if (on) $('r-draft').focus()

}



function show(index) {

  open = index

  const asset = items[index]

  const state = stateOf(asset)

  $('r-image').src = asset.display_url

  $('r-image').alt = asset.title || ''

  $('r-position').textContent = `${index + 1} of ${items.length}`

  $('r-kicker').textContent = [asset.uploaded_by?.name, day(asset.created_at)].filter(Boolean).join(' · ')

  $('r-title').textContent = asset.title

  const pill = el('span', state, LABELS[state])

  $('r-state').replaceChildren(pill)

  const caption = plain(asset.description)

  $('r-caption').textContent = caption

  $('r-caption').hidden = $('r-caption-head').hidden = !caption

  $('r-error').textContent = ''

  $('r-draft').value = ''

  setRequesting(false)

  renderThread(asset)

  loadThread(asset).catch(() => $('r-thread').replaceChildren(el('li', 'empty', 'Feedback did not load.')))

  $('review').hidden = false

  document.body.classList.add('reviewing')

}



function route() {

  const token = location.hash.match(/^#\/(.+)$/)?.[1]

  const index = items.findIndex((asset) => asset.token === token)

  if (index >= 0) return show(index)

  open = -1

  $('review').hidden = true

  document.body.classList.remove('reviewing')

  renderHome()

}



function goTo(index) {

  history.replaceState(null, '', `#/${items[index].token}`)

  route()

}

const go = (step) => goTo((open + step + items.length) % items.length)

function close() {

  history.replaceState(null, '', location.pathname + location.search)

  route()

}



// After a decision: the next asset that still needs review, or home when none is left.

function advance() {

  const after = [...items.slice(open + 1), ...items.slice(0, open)]

  const next = after.find((asset) => stateOf(asset) === 'waiting')

  if (next) goTo(items.indexOf(next))

  else {

    close()

    toast('Everything is reviewed. Thank you!')

  }

}



async function submit({ value, note }) {

  const asset = items[open]

  const buttons = [$('r-approve'), $('r-changes'), $('r-send')]

  buttons.forEach((button) => (button.disabled = true))

  $('r-error').textContent = ''

  try {

    // Each change answers with the result. Reads stay cached for minutes, so the

    // page keeps the answer instead of reading the board again.

    if (value) {

      const { data } = await call(`assets/${asset.token}`, { method: 'PATCH', body: { asset: { fields: { [FIELD]: value } } } })

      asset.fields = data.fields

    }

    if (note) {

      const { data } = await call('comments', { method: 'POST', body: { asset_token: asset.token, comment: { description: note } } })

      threads.set(asset.token, [...(threads.get(asset.token) || []), data])

      asset.comment_count = (asset.comment_count || 0) + 1

    }

    if (value) {

      toast(value === APPROVED ? 'Approved' : 'Changes requested')

      advance()

    } else {

      $('r-draft').value = ''

      renderThread(asset)

    }

  } catch (error) {

    $('r-error').textContent = error.message

  } finally {

    buttons.forEach((button) => (button.disabled = false))

  }

}



const approve = () => submit({ value: APPROVED, note: $('r-draft').value.trim() })

const requestChanges = () => ($('r-draft').value.trim() ? submit({ value: CHANGES, note: $('r-draft').value.trim() }) : setRequesting(true))



$('r-chips').append(

  ...CHIPS.map((text) => {

    const chip = el('button', '', text)

    chip.type = 'button'

    chip.addEventListener('click', () => {

      const draft = $('r-draft')

      draft.value = draft.value.trim() ? `${draft.value.trim()} ${text}.` : `${text}.`

      draft.focus()

    })

    return chip

  })

)

$('r-composer').addEventListener('submit', (event) => {

  event.preventDefault()

  const note = $('r-draft').value.trim()

  if (requesting && !note) return $('r-draft').focus()

  if (note) submit({ value: requesting ? CHANGES : null, note })

})

$('r-draft').addEventListener('keydown', (event) => {

  if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) $('r-composer').requestSubmit()

})

// Light or dark: the visitor's pick, kept per app, else the system's.

$('theme').addEventListener('click', () => {

  const root = document.documentElement

  const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches

  root.dataset.theme = dark ? 'light' : 'dark'

  try {

    localStorage.setItem(`theme:${location.pathname}`, root.dataset.theme)

  } catch {}

})

$('r-approve').addEventListener('click', approve)

$('r-changes').addEventListener('click', requestChanges)

$('r-cancel').addEventListener('click', () => setRequesting(false))

$('r-prev').addEventListener('click', () => go(-1))

$('r-next').addEventListener('click', () => go(1))

$('r-close').addEventListener('click', close)

$('start').addEventListener('click', () => {

  const first = items.findIndex((asset) => stateOf(asset) === 'waiting')

  if (first >= 0) goTo(first)

})

const KEYS = { ArrowLeft: () => go(-1), ArrowRight: () => go(1), Escape: close, a: approve, r: requestChanges }

document.addEventListener('keydown', (event) => {

  if (open < 0 || event.target.closest('textarea, input') || event.metaKey || event.ctrlKey || !KEYS[event.key]) return

  // Without this, the "r" that focuses the note would also be typed into it.

  event.preventDefault()

  KEYS[event.key]()

})

window.addEventListener('hashchange', route)



try {

  const app = await (await fetch('playbook/app')).json()

  const assets = []

  for (let page = 1; ; page++) {

    const { data, pagy } = await call(`assets?per_page=100&page=${page}`)

    assets.push(...data)

    if (!pagy || pagy.current_page >= pagy.total_pages) break

  }

  const { data: board } = await call(`boards/${app.board}`)

  items = assets.filter((asset) => !asset.is_group && asset.thumbnails?.length)

  document.title = `${board.title} · Review`

  $('title').textContent = board.title

  $('subtitle').textContent = plain(board.description)

  route()

} catch {

  $('title').textContent = ''

  $('notice').hidden = false

}
```

## Make it yours[​](#make-it-yours "Direct link to Make it yours")

Change:

* **`FIELD`, `APPROVED` and `CHANGES`** in `app.js`: they must match the field and its options exactly, as `list_custom_fields` spells them.
* **`CHIPS`**: the quick replies, in the words your reviewers use.
* **`LABELS`**: how each state reads on the page.
* **Colours.** The tokens at the top of `styles.css`: the dark set on `:root`, the light set twice below it. Change both together.
* **Type and the eyebrow.** The Google Fonts link and the Review line in `index.html`.

Keep:

* Comments read when an asset opens, never for the whole grid: each read is uncached and counts toward the visitor's limit.
* The answer of each change kept on the page: reads are cached for up to five minutes.
* `event.preventDefault()` in the key handler: without it, the R that opens the note is typed into it.
* The reload on `401`: it takes the visitor back through sign-in.

## Check it[​](#check-it "Direct link to Check it")

Sign in as someone who can edit the board. Open the app 390 px wide and 1280 px wide, in both themes. Start the review, approve one asset with the A key, and check in Playbook that its `Review` field changed; set it back. Request changes on another with a note and check the comment arrived.
