# Recipe: Upload against a brief

A page for one person who sends files to the team: their name, the week and the due date, the brief, and one card per task. Each card has its own upload; a file shows at once with its progress, then waits for review. When the team asks for a redo, the card turns orange with the team's note, and a button always points at the next thing to do. 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")

* Someone outside the team sends files for a list of tasks: a creator filming scripts, a photographer delivering a shot list, a supplier sending product images.
* One board per person. Give each their own app, or point one app at each person's board.
* Not for showing a board (use the [gallery](/docs/guides/hosted_apps_recipe_gallery.md)) or for the team deciding on work (use [review](/docs/guides/hosted_apps_recipe_review.md)).

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

Everything the person reads comes from their board, so the team changes the brief in Playbook and the page follows within five minutes, with no redeploy.

* **The board's title** is the person's name.
* **The board's description** is the brief, one item per line. Every line is optional; lines the page does not recognise become the introduction.

```
Week 12

Due: Friday, 20 March

Hi Sam! Four short pieces about the coast this week. Film vertical and keep each under 30 seconds.

Scripts: https://example.com/scripts/week-12

Script 1: Morning swim, in one take

Script 2: What the tide left behind

Script 3: Three spots I'd show a friend

Notes:

Daylight only, no ring light.

Leave two seconds of quiet at the start and the end.
```

* **Uploads** are titled `Script 2 – take-1.mp4` by the page. The prefix is how a file finds its task, so the team should keep it when renaming. Files sent under Extras keep their own names.
* **Review happens in Playbook.** The status `Changes requested`, with the reason in the asset's description, turns the task into a redo and shows the reason as a note. `Approved` marks it done. Both statuses must exist in the workspace.
* **Uploads must be allowed**: the board's share link has Allow uploads (no password, no email form), or the visitor signs in as someone who may upload. See [uploads from visitors](/docs/guides/hosted_apps.md#uploads-from-visitors).

Use `password` access for one person, and tell them the password apart from the link.

## 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, viewport-fit=cover" />

<title>Your tasks</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=Manrope:wght@400;500;600;700;800&display=swap" />

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

</head>

<body>

<main class="page">

  <header class="head">

    <div class="head-top">

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

      <p class="due" id="due" hidden></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>

    </div>

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

    <h1 id="name">Your tasks</h1>

    <div class="meter" aria-hidden="true"><span id="meter"></span></div>

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

  </header>



  <section class="intro" id="intro"></section>

  <a class="link" id="link" target="_blank" rel="noopener" hidden>Scripts <span aria-hidden="true">↗</span></a>



  <h2>Tasks</h2>

  <ol class="tasks" id="tasks"></ol>



  <section class="notes" id="notes-section" hidden>

    <h2>Good to know</h2>

    <ul id="notes"></ul>

  </section>



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

</main>



<button type="button" class="next" id="next" hidden></button>



<script src="https://unpkg.com/playbook-sdk@1.3.0/dist/playbook-uploader.min.js" integrity="sha384-b5Wn9OnIWgIC9GXRziMnfqcsz1LE68hXvMMkT+8zurQM+HxWsQ87nSVOVzoWtsS2" crossorigin="anonymous"></script>

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

</body>

</html>
```

`styles.css`:

```
/* Light by default, dark 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: light;

  --bg: #f5f4ef;

  --card: #ffffff;

  --ink: #16181d;

  --soft: #4a4d57;

  --muted: #868994;

  --line: #e6e4dc;

  --accent: #3b5bfd;

  --accent-soft: #e8ecff;

  --on-accent: #ffffff;

  --ok: #1f9d6b;

  --ok-soft: #e1f5ec;

  --redo: #e0592a;

  --redo-soft: #fdebe3;

  --font: 'Manrope', system-ui, sans-serif;

}

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

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

    color-scheme: dark;

    --bg: #111215; --card: #1a1c21; --ink: #f1f2f4; --soft: #b9bcc6; --muted: #80838e; --line: #2a2d34;

    --accent: #7b93ff; --accent-soft: #1e2550; --on-accent: #0b1033;

    --ok: #3fcf8e; --ok-soft: #12301f; --redo: #ff8a5c; --redo-soft: #3a1d12;

  }

}

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

  color-scheme: dark;

  --bg: #111215; --card: #1a1c21; --ink: #f1f2f4; --soft: #b9bcc6; --muted: #80838e; --line: #2a2d34;

  --accent: #7b93ff; --accent-soft: #1e2550; --on-accent: #0b1033;

  --ok: #3fcf8e; --ok-soft: #12301f; --redo: #ff8a5c; --redo-soft: #3a1d12;

}



* { box-sizing: border-box; }

[hidden] { display: none !important; }

body {

  margin: 0;

  background: var(--bg);

  color: var(--ink);

  font: 16px/1.55 var(--font);

  -webkit-font-smoothing: antialiased;

}

button { font: inherit; color: inherit; cursor: pointer; }

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



.page { max-width: 640px; margin: 0 auto; padding: 20px 16px 120px; }



/* Header: who, when, how far along. */

.head-top { display: flex; align-items: center; gap: 8px; }

.team { flex: 1; }

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

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

.team { margin: 0; font-weight: 700; font-size: 14px; }

.due { margin: 0; padding: 5px 12px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-size: 13px; font-weight: 700; }

.week { margin: 40px 0 4px; color: var(--muted); font-size: 13px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }

h1 { margin: 0; font-size: clamp(40px, 11vw, 64px); font-weight: 800; line-height: 1; letter-spacing: -0.04em; overflow-wrap: anywhere; }

.meter { height: 4px; margin-top: 24px; overflow: hidden; border-radius: 2px; background: var(--line); }

.meter span { display: block; width: 0; height: 100%; background: var(--accent); transition: width 0.5s ease; }

.progress { margin: 8px 0 0; color: var(--soft); font-size: 14px; font-weight: 600; }



.intro { margin-top: 28px; }

.intro p { max-width: 58ch; margin: 0 0 10px; color: var(--soft); font-size: 17px; }

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

.link { display: inline-block; margin-top: 4px; color: var(--accent); font-weight: 700; text-decoration: none; }

.link:hover { text-decoration: underline; }



h2 { margin: 40px 0 12px; font-size: 13px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }



/* Tasks: one card each; the open one shows its uploads. */

.tasks { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }

.task { border: 1px solid var(--line); border-radius: 16px; background: var(--card); }

.task-head { display: grid; grid-template-columns: 32px 1fr auto; align-items: center; gap: 12px; width: 100%; padding: 14px 14px 14px 12px; border: 0; background: none; text-align: left; }

.num { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; background: var(--bg); font-size: 14px; font-weight: 800; }

.task-text { display: grid; min-width: 0; }

.task-title { font-weight: 600; line-height: 1.3; }

.count { color: var(--muted); font-size: 13px; }

.count:empty { display: none; }

.chip { padding: 3px 10px; border-radius: 999px; background: var(--bg); color: var(--soft); font-size: 12px; font-weight: 700; white-space: nowrap; }



.task[data-state='sent'] .chip { background: var(--accent-soft); color: var(--accent); }

.task[data-state='ok'] .chip, .task[data-state='ok'] .num { background: var(--ok-soft); color: var(--ok); }

.task[data-state='ok'] .task-title { color: var(--muted); }

.task[data-state='redo'] { border-color: var(--redo); }

.task[data-state='redo'] .chip, .task[data-state='redo'] .num { background: var(--redo-soft); color: var(--redo); }

.task[data-state='extra'] .chip { background: none; color: var(--muted); }



.note { margin: 0 14px 12px 56px; padding: 10px 12px; border-left: 3px solid var(--redo); border-radius: 0 10px 10px 0; background: var(--redo-soft); }

.note-from { margin: 0; color: var(--redo); font-size: 12px; font-weight: 700; }

.note-text { margin: 2px 0 0; font-size: 15px; line-height: 1.45; }



.task-body { padding: 0 14px 14px 56px; }

.strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 2px; scrollbar-width: thin; }

.drop, .thumb { flex: none; width: 92px; aspect-ratio: 9 / 16; }

.thumb { position: relative; margin: 0; overflow: hidden; border-radius: 10px; background: var(--line); }

.thumb img, .thumb video { display: block; width: 100%; height: 100%; object-fit: cover; }

.thumb-text { position: absolute; inset: 0; display: grid; place-items: center; padding: 8px; color: var(--muted); font-size: 11px; line-height: 1.3; text-align: center; }

.thumb-mark { position: absolute; top: 6px; right: 6px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; color: #fff; font-size: 12px; font-weight: 800; }

.thumb-mark.ok { background: var(--ok); }

.thumb-mark.redo { background: var(--redo); }

.is-pending img, .is-pending video { opacity: 0.55; }

.thumb-bar { position: absolute; left: 0; bottom: 0; width: 0; height: 4px; background: var(--accent); transition: width 0.2s linear; }

.is-failed { background: var(--redo-soft); cursor: pointer; }

.is-failed .thumb-text { color: var(--redo); }



/* The Playbook uploader's button, drawn as the first tile of the strip; its own

   file list is replaced by the tiles. */

.drop .pb-uploader-button {

  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;

  width: 100%; height: 100%; padding: 8px;

  border: 1.5px dashed var(--accent); border-radius: 10px; background: var(--accent-soft);

  color: var(--accent); font: 700 13px/1.2 var(--font);

}

.drop .pb-uploader-button::before { content: '↑'; font-size: 22px; line-height: 1; }

.drop .pb-uploader-button:hover { background: color-mix(in srgb, var(--accent) 18%, var(--card)); }

.drop .pb-uploader-list { display: none; }



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

.notes li { margin-bottom: 8px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 16px; background: var(--card); color: var(--soft); font-size: 15px; }



.notice { margin-top: 24px; padding: 14px 16px; border-radius: 12px; background: var(--redo-soft); color: var(--redo); }



/* The next thing to do, always one tap away. */

.next {

  position: fixed; right: 16px; bottom: calc(16px + env(safe-area-inset-bottom)); z-index: 10;

  padding: 14px 22px; border: 0; border-radius: 999px; background: var(--accent); color: var(--on-accent);

  font-weight: 700; box-shadow: 0 10px 30px color-mix(in srgb, var(--accent) 35%, transparent);

}

.next:hover { filter: brightness(1.08); }

/* On a wide screen, on the right edge of the column, not of the window. */

@media (min-width: 700px) {

  .next { right: calc(50% - 304px); bottom: 24px; }

}



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

  * { transition: none !important; }

}
```

`app.js`:

```
// A contributor's task page. Everything on it comes from the board: its title is

// the person's name and its description is the brief, written as lines:

//   Week 12

//   Due: Friday, 20 March

//   <intro paragraphs>

//   Scripts: <link>

//   Script 1: <task>    (one line per task)

//   Notes:              (every line after it is a note)

// An upload belongs to task N when its title starts with "Script N –". The team

// sets the status "Changes requested" and writes why in the description;

// "Approved" marks it done.



const TEAM = 'Studio North'

const LABELS = { todo: 'To do', sent: 'In review', ok: 'Done', redo: 'Redo', extra: 'Optional' }



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

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

  const node = document.createElement(tag)

  if (className) node.className = className

  if (text !== undefined) node.textContent = text

  return node

}

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



async function api(path, tries = 3) {

  const response = await fetch(`playbook/api/${path}`).catch(() => null)

  if (response?.ok) return response.json()

  const status = response?.status ?? 0

  if (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 api(path, tries - 1)

  }

  throw new Error(`Playbook answered ${status}`)

}



async function post(path, body) {

  const response = await fetch(path, {

    method: 'POST',

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

    body: JSON.stringify(body),

  })

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

  if (!response.ok) throw new Error(answer.errors?.[0]?.message || answer.error || 'The upload did not go through.')

  return answer.data

}



// Rich text or plain text, as lines.

function linesOf(html) {

  const text = (html || '').replace(/<br\s*\/?>/gi, '\n').replace(/<\/(p|div|li|h[1-6])>/gi, '\n')

  return new DOMParser().parseFromString(text, 'text/html').body.textContent.split('\n').map((line) => line.trim())

}



function parseBrief(html) {

  const brief = { week: null, due: null, intro: [], link: null, tasks: [], notes: [] }

  let inNotes = false

  for (const line of linesOf(html)) {

    if (!line) continue

    let match

    if (!brief.week && (match = line.match(/^week\s+(\d+)$/i))) brief.week = match[1]

    else if (!brief.due && (match = line.match(/^due\s*:?\s*(.+)$/i))) brief.due = match[1]

    else if ((match = line.match(/^script\s+(\d+)\s*[:.–—-]\s*(.+)$/i))) brief.tasks.push({ n: Number(match[1]), title: match[2] })

    else if (/^notes?\s*:?$/i.test(line)) inNotes = true

    else if (!brief.link && (match = line.match(/^[^:]{0,20}:?\s*(https?:\/\/\S+)$/))) brief.link = match[1]

    else (inNotes ? brief.notes : brief.intro).push(line)

  }

  return brief

}



const taskOf = (title) => Number((title || '').match(/^script\s+(\d+)\s*[–—-]/i)?.[1]) || null



// The latest round decides: files sent after a redo request answer it.

function stateOf(uploads) {

  if (!uploads.length) return { key: 'todo' }

  const isRedo = (upload) => /change|reshoot|again|redo/i.test(upload.status || '')

  const time = (upload) => (upload.localFile ? Infinity : Date.parse(upload.created_at) || 0)

  const byTime = [...uploads].sort((a, b) => time(a) - time(b))

  const lastRedo = byTime.findLastIndex(isRedo)

  const since = byTime.slice(lastRedo + 1)

  if (!since.length) return { key: 'redo', note: plain(byTime[lastRedo].description) }

  if (since.every((upload) => /approv|done/i.test(upload.status || ''))) return { key: 'ok' }

  return { key: 'sent' }

}



function preview(file) {

  if (file.type.startsWith('image/')) return Object.assign(el('img'), { src: URL.createObjectURL(file), alt: '' })

  return Object.assign(el('video'), { muted: true, playsInline: true, preload: 'metadata', src: `${URL.createObjectURL(file)}#t=0.1` })

}



function thumb(upload) {

  const tile = el('figure', 'thumb')

  if (upload.localFile) tile.append(preview(upload.localFile))

  else {

    const renders = [...(upload.thumbnails || [])].sort((a, b) => a.width - b.width)

    const src = (renders.find((t) => t.width >= 320) || renders.at(-1))?.url || upload.display_url

    if (src) tile.append(Object.assign(el('img'), { src, alt: '', loading: 'lazy' }))

    else tile.append(el('figcaption', 'thumb-text', 'Processing…'))

  }

  const state = stateOf([upload]).key

  if (state === 'ok' || state === 'redo') tile.append(el('span', `thumb-mark ${state}`, state === 'ok' ? '✓' : '↻'))

  return tile

}



const tasks = []



function refresh(task) {

  const state = task.n || task.uploads.length ? stateOf(task.uploads) : { key: 'extra' }

  task.state = state.key

  task.row.dataset.state = state.key

  task.chip.textContent = LABELS[state.key]

  task.noteText.textContent = state.note || ''

  task.note.hidden = !state.note

  const count = task.uploads.length + task.pending.size

  task.count.textContent = count ? `${count} ${count === 1 ? 'file' : 'files'}` : ''

  task.strip.replaceChildren(task.drop, ...[...task.pending.values()].map((p) => p.tile), ...task.uploads.map(thumb))

  renderProgress()

}



function nextTask() {

  return tasks.find((task) => task.state === 'redo') || tasks.find((task) => task.n && task.state === 'todo')

}



function renderProgress() {

  const numbered = tasks.filter((task) => task.n)

  if (!numbered.length) return

  const sent = numbered.filter((task) => task.uploads.length).length

  const done = numbered.filter((task) => task.state === 'ok').length

  $('meter').style.width = `${(sent / numbered.length) * 100}%`

  $('progress').textContent = sent === numbered.length

    ? `All ${sent} sent. Thank you!`

    : `${sent} of ${numbered.length} sent${done ? ` · ${done} done` : ''}`

  const next = nextTask()

  $('next').hidden = !next

  if (next) $('next').textContent = `${next.state === 'redo' ? 'Redo' : 'Upload'} task ${next.n}`

}



function pending(task, file) {

  const tile = el('figure', 'thumb is-pending')

  const bar = el('span', 'thumb-bar')

  tile.append(preview(file), bar)

  task.pending.set(file, { tile, bar })

}



function mountUploader(task) {

  if (task.uploader || !window.PlaybookUploader) return

  task.uploader = PlaybookUploader.init({

    containerId: task.dropId,

    variant: 'button',

    accept: 'video/*,image/*',

    // Playbook enforces the size limit and answers with a readable message.

    maxFileSizeBytes: Number.MAX_SAFE_INTEGER,

    labels: { button: 'Upload' },

    getUploadTarget: (file) =>

      post('playbook/upload', {

        title: task.n ? `Script ${task.n} – ${file.name}` : file.name,

        size: file.size,

        media_type: file.type || undefined,

      }),

    finishUpload: (target) =>

      post('playbook/upload/complete', {

        signed_gcs_id: target.signed_gcs_id,

        multipart_upload_id: target.multipart_upload_id || undefined,

      }),

    onSelect: (files) => {

      files.forEach((file) => pending(task, file))

      refresh(task)

    },

    onProgress: (file, pct) => task.pending.get(file)?.bar.style.setProperty('width', `${Math.round(pct)}%`),

    // Reads are cached for minutes, so the answer is shown rather than read back.

    onFileComplete: (asset, file) => {

      task.pending.delete(file)

      task.uploads.unshift({ ...asset, localFile: file })

      refresh(task)

    },

    onError: (error, file) => {

      if (!task.pending.has(file)) pending(task, file)

      const { tile } = task.pending.get(file)

      tile.className = 'thumb is-failed'

      tile.replaceChildren(el('figcaption', 'thumb-text', `${error?.message || 'The upload did not go through.'} Tap to dismiss.`))

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

        task.pending.delete(file)

        refresh(task)

      })

      refresh(task)

    },

  })

}



function setOpen(task, open) {

  task.head.setAttribute('aria-expanded', String(open))

  task.body.hidden = !open

  if (open) mountUploader(task)

}



function buildTask({ n, title }) {

  const task = { n, uploads: [], pending: new Map(), dropId: `drop-${n ?? 'extra'}` }

  task.row = el('li', 'task')

  task.head = el('button', 'task-head')

  task.head.type = 'button'

  task.chip = el('span', 'chip')

  task.count = el('span', 'count')

  const text = el('span', 'task-text')

  text.append(el('span', 'task-title', title), task.count)

  task.head.append(el('span', 'num', n ? String(n) : '+'), text, task.chip)



  task.note = el('div', 'note')

  task.note.append(el('p', 'note-from', `Note from ${TEAM}`))

  task.noteText = el('p', 'note-text')

  task.note.append(task.noteText)



  task.body = el('div', 'task-body')

  task.strip = el('div', 'strip')

  task.drop = el('div', 'drop')

  task.drop.id = task.dropId

  task.body.append(task.strip)



  task.row.append(task.head, task.note, task.body)

  task.head.addEventListener('click', () => setOpen(task, task.body.hidden))

  setOpen(task, false)

  return task

}



async function loadAssets() {

  const assets = []

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

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

    assets.push(...data)

    if (!pagy || pagy.current_page >= pagy.total_pages) return assets

  }

}



// 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 {}

})



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

  const next = nextTask()

  if (!next) return

  setOpen(next, true)

  next.row.scrollIntoView({ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' })

  next.drop.querySelector('.pb-uploader-button')?.click()

})



try {

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

  const [{ data: board }, assets] = await Promise.all([api(`boards/${app.board}`), loadAssets()])

  const brief = parseBrief(board.description)



  document.title = `${board.title} · ${brief.week ? `Week ${brief.week}` : 'Tasks'}`

  $('team').textContent = TEAM

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

  $('week').textContent = brief.week ? `Week ${brief.week}` : 'This week'

  if (brief.due) {

    $('due').textContent = `Due ${brief.due.replace(/^[a-z]+,?\s*/i, '')}`

    $('due').hidden = false

  }

  $('intro').append(...(brief.intro.length ? brief.intro.map((line) => el('p', '', line)) : [el('p', 'muted', 'Your brief will appear here soon.')]))

  if (brief.link) {

    $('link').href = brief.link

    $('link').hidden = false

  }

  if (brief.notes.length) {

    $('notes').append(...brief.notes.map((note) => el('li', '', note)))

    $('notes-section').hidden = false

  }



  const known = new Set(brief.tasks.map((task) => task.n))

  for (const item of [...brief.tasks, { n: null, title: brief.tasks.length ? 'Extras' : 'Your uploads' }]) tasks.push(buildTask(item))

  for (const asset of assets) {

    const n = taskOf(asset.title)

    tasks.find((task) => task.n === (known.has(n) ? n : null)).uploads.push(asset)

  }

  $('tasks').append(...tasks.map((task) => task.row))

  tasks.forEach(refresh)

  const next = nextTask()

  if (next) setOpen(next, true)

} catch {

  $('notice').hidden = false

}
```

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

Change:

* **`TEAM`** in `app.js`: the name in the header and on notes.
* **`LABELS`**: the words on the task chips.
* **The word "Script".** It appears in three places that must change together: the two patterns in `parseBrief` and `taskOf`, and the title in `getUploadTarget`.
* **Colours.** The tokens at the top of `styles.css`: the light set on `:root`, the dark set twice below it. Change both together and keep text readable on `--accent`.
* **Type.** The Google Fonts link in `index.html` and `--font`.

Keep:

* `maxFileSizeBytes: Number.MAX_SAFE_INTEGER` and playbook-sdk 1.3.0: Playbook enforces its own limit (2 GB) with a readable message, and the uploader sends large files in parts and resumes them.
* `onFileComplete` adding the new file to the task: reads are cached for up to five minutes, so reading the board again would not show it yet.
* The title prefix on every upload, so the team can see which task a file is for.

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

Open the app 390 px wide and 1280 px wide, in both themes. Upload a small image to the first task: it should show in the task's strip at once, with a progress bar, and the task should turn In review. After five minutes, reload and see it come back from the board.
