ModelLineup

Every answer on this page was written by an AI model, named on the answer. Answers can be wrong, out of date or made up, even when they sound sure. Check anything that matters before you rely on it.

Max effort results for this model are being collected again. We capped every answer at 32,000 output tokens, below this model’s own output limit, so many max effort answers ran out of room while still thinking and scored zero. Low effort results are not affected. The new max effort answers will replace these.

Models Opus 5

Claude Opus 5

Released
Tested
Settings
Low effort and Max effort

Opus 5

24 prompts, every check counted

Low effort

87

  • Floor45/49, 92% passed
  • Middle51/58, 88% passed
  • Top57/65, 88% passed

Median answer 17 s, 4.7¢.

Max effort

91

  • Floor45/49, 92% passed
  • Middle52/58, 90% passed
  • Top59/65, 91% passed

Median answer 1 min 1 s, 14¢.

Max passed 3 more checks, cost 3.4 times as much and took 3.8 times as long. Costs are at Anthropic’s API prices on .

What the results show

Written by an AI model from these results; every figure in it is filled in by a program. How it was made.

Opus 5 answered all 24 prompts at both efforts on 1 October 2026, scoring 87 at low effort and 91 at max. It passed every hidden test on every code prompt at both settings; its misses were mostly planning details at low effort, figures taken on trust, and a kanban board that ran out of room at max effort.

Best at

  • Code: 100 at low effort and 100 at max; it passed every hidden test on every code prompt, including the paging bug, the search box race, the CSV import and the slow script. F01 F02 F06 F07
  • Web pages: the landing page at low effort, the dashboard from the mockup at max effort, the kanban board at low effort and the garden page fixes at both passed every page test. B01 B02 B03 B05
  • Careful reading: it found the wrong line on the market receipt, said it did not know the invented novel, and kept its bread maker answers to what the manual says, at both efforts. I01 H02 K02
  • Everyday writing and the rota: the text to the plumber, the update from a messy thread and the volunteer rota met every check at both efforts, as did the microservices answer at max effort. W01 W02 P02 A02

Stumbled on

  • At max effort the kanban board ran out of room and failed every page test, and the landing page failed its readability check; Minesweeper failed the reveal rules at both efforts, and flags and new game at low. B03 B01 B04
  • At low effort the column rename plan renamed the column in place while services still read the old name, with no new column, batched backfill or concurrent index; at max effort the final drop still came too soon. A03
  • At low effort the feature spec did not fully flag the role and seat questions, missed a decision and settled choices without marking them; the microservices answer ran over length and offered no modular monolith as a middle path. A04 A02
  • It took the cycling figures' printed total on trust at both efforts, did not use every required word in the short story, and both booking plans left a working booking flow out of the first phase. R02 S02 A01

What max effort changed

  • Overall it scored level at both efforts: 6 prompts gained at max effort, 2 lost ground and 16 stayed the same, for about 3.4 times the cost and 3.8 times the time.
  • Planning rose from 65 to 91: the column rename plan went from 4 of 9 checks to 8 of 9, the feature spec from 4 of 8 to 7 of 8, and the microservices answer met every check at max. A03 A04 A02
  • Web pages fell from 90 to 76, mostly because the kanban board ran out of room and went from 9 of 9 checks to 0 of 9; the dashboard and the Minesweeper game both passed more checks at max. B03 B02 B04

In short

At low effort Opus 5 scored level with Fable 5, and at max effort it again scored level with Fable 5. Overall it scored level at both efforts; max effort lifted planning from 65 to 91 but lost the kanban board, which ran out of room.

Worth knowing

  • Each prompt was answered once at each effort, so a single answer can move a prompt's score, and small gaps between scores are treated as level.
  • Every model gets the same output allowance. An answer that runs out of room is scored on whatever it did write, so an unfinished page or program usually fails its tests.
  • Costs are what the same tokens would cost at the published API prices, not what anyone paid.

Score by kind of work

Building web pages

  • Low effort
    90
  • Max effort
    76

Code

  • Low effort
    100
  • Max effort
    100

Planning and architecture

  • Low effort
    65
  • Max effort
    91

Everyday

  • Low effort
    93
  • Max effort
    96

Every answer

Each row opens to the low and max answers side by side. Each prompt is described on the prompts page.

Building web pages

B01A landing page for a small invoicing appLow 9/9Max 8/9Max −1

What it asks for. Asks for a one-page landing site for a small invoicing app for freelancers, as a single HTML file with no libraries and nothing loaded from outside. The page needs a headline, three named features, three price plans with a monthly and yearly switch (yearly gives two months free and is quoted as one total for the year), a four-question FAQ that opens and closes, and a sign-up button. It has to read well on a phone.

Max effort passed 1 fewer check than low.

AI answer from Opus 5 · low effort · 1 Oct 2026

9 of 9

Screenshot of the page built in this answer, at a desktop width of 1280 pixels
Our screenshot, 1280 by 800. Open it for the full length.
Screenshot of the page built in this answer, at a phone width of 375 pixels
Phone, 375 by 812.
More screenshots (2).
Screenshot of the page built in this answer, at a desktop width of 1280 pixelsScreenshot of the page built in this answer, at a phone width of 375 pixels
Show the code and the answer.

Here's the complete file — save it as index.html.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Brindlebill — invoicing for freelancers</title>
<style>
  :root {
    --ink: #1c2024;
    --muted: #55606b;
    --line: #e2e6ea;
    --accent: #1f5f52;
    --accent-soft: #eaf3f0;
    --bg: #fbfaf8;
  }
  * { box-sizing: border-box; }
  html { -webkit-text-size-adjust: 100%; }
  body {
    margin: 0;
    background: var(--bg);
    color: var(--ink);
    font: 17px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  }
  .wrap { width: 100%; max-width: 62rem; margin: 0 auto; padding: 0 1.25rem; }
  section { padding: 3rem 0; border-top: 1px solid var(--line); }
  h1, h2, h3 { line-height: 1.25; margin: 0 0 .6rem; }
  h1 { font-size: 2rem; letter-spacing: -.02em; }
  h2 { font-size: 1.5rem; }
  h3 { font-size: 1.125rem; }
  p { margin: 0 0 1rem; }
  .muted { color: var(--muted); }

  header.hero { padding: 3.5rem 0 3rem; }
  .brand {
    font-weight: 700; letter-spacing: .02em; text-transform: uppercase;
    font-size: 1rem; color: var(--accent); margin-bottom: 1.5rem;
  }
  .pitch { font-size: 1.1875rem; color: var(--muted); max-width: 34rem; }

  .btn {
    display: inline-block; font: inherit; font-weight: 600; cursor: pointer;
    background: var(--accent); color: #fff; border: 1px solid var(--accent);
    padding: .875rem 1.5rem; border-radius: .5rem; text-decoration: none;
    min-height: 48px; line-height: 1.4;
  }
  .btn:hover { background: #17483e; }
  .btn:focus-visible, summary:focus-visible, .toggle button:focus-visible {
    outline: 3px solid #9ccfc2; outline-offset: 2px;
  }
  .hero .btn { margin-top: .5rem; }
  .trial-note { font-size: 1rem; margin-top: .875rem; }

  .features { display: grid; gap: 1.5rem; }
  .feature h3 { margin-bottom: .25rem; }
  .feature p { margin: 0; color: var(--muted); }

  .toggle {
    display: inline-flex; gap: .25rem; padding: .25rem; margin: 0 0 1.75rem;
    background: #fff; border: 1px solid var(--line); border-radius: .625rem;
  }
  .toggle button {
    font: inherit; font-weight: 600; color: var(--muted);
    background: none; border: 0; border-radius: .5rem;
    padding: .625rem 1.125rem; min-height: 44px; cursor: pointer;
  }
  .toggle button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }

  .plans { display: grid; gap: 1.25rem; list-style: none; padding: 0; margin: 0; }
  .plan {
    background: #fff; border: 1px solid var(--line);
    border-radius: .75rem; padding: 1.5rem;
  }
  .plan.featured { border-color: var(--accent); border-width: 2px; }
  .tag {
    display: inline-block; font-size: 1rem; font-weight: 600; color: var(--accent);
    background: var(--accent-soft); border-radius: 999px; padding: .125rem .625rem;
    margin-bottom: .5rem;
  }
  .price { font-size: 2rem; font-weight: 700; letter-spacing: -.02em; }
  .per { font-size: 1rem; font-weight: 400; color: var(--muted); }
  .save { font-size: 1rem; color: var(--accent); margin: .25rem 0 1rem; min-height: 1.6em; }
  .plan ul { margin: 0; padding-left: 1.25rem; color: var(--muted); }
  .plan li { margin-bottom: .375rem; }

  details {
    background: #fff; border: 1px solid var(--line);
    border-radius: .625rem; margin-bottom: .75rem;
  }
  summary {
    cursor: pointer; font-weight: 600; padding: 1rem 1.25rem;
    list-style: none; display: flex; justify-content: space-between; gap: 1rem;
  }
  summary::-webkit-details-marker { display: none; }
  summary::after { content: "+"; color: var(--accent); font-size: 1.375rem; line-height: 1; }
  details[open] summary::after { content: "–"; }
  details p { padding: 0 1.25rem 1.125rem; margin: 0; color: var(--muted); }

  footer { border-top: 1px solid var(--line); padding: 2rem 0 3rem; font-size: 1rem; color: var(--muted); }

  @media (min-width: 40rem) {
    h1 { font-size: 2.75rem; }
    .features { grid-template-columns: repeat(3, 1fr); gap: 2rem; }
    .plans { grid-template-columns: repeat(3, 1fr); }
    section { padding: 4rem 0; }
  }
</style>
</head>
<body>

<header class="hero">
  <div class="wrap">
    <p class="brand">Brindlebill</p>
    <h1>Send an invoice today, get paid this week.</h1>
    <p class="pitch">Brindlebill is small, calm invoicing software for freelancers who would rather be doing the work.</p>
    <a class="btn" href="#signup">Start free trial</a>
    <p class="trial-note muted">Free for 14 days. No card needed.</p>
  </div>
</header>

<section aria-labelledby="features-h">
  <div class="wrap">
    <h2 id="features-h">What you get</h2>
    <div class="features">
      <div class="feature">
        <h3>Invoices in a minute</h3>
        <p>Pick a client, add a line or two, and send — your details and totals fill themselves in.</p>
      </div>
      <div class="feature">
        <h3>Gentle reminders</h3>
        <p>Brindlebill nudges late payers politely on your behalf, so you never have to write that email.</p>
      </div>
      <div class="feature">
        <h3>Card and bank payments</h3>
        <p>Clients pay by card or bank transfer straight from the invoice, and the money lands in your account.</p>
      </div>
    </div>
  </div>
</section>

<section aria-labelledby="pricing-h">
  <div class="wrap">
    <h2 id="pricing-h">Pricing</h2>
    <p class="muted">Pay yearly and get two months free.</p>

    <div class="toggle" role="group" aria-label="Billing period">
      <button type="button" id="btn-monthly" aria-pressed="true">Monthly</button>
      <button type="button" id="btn-yearly" aria-pressed="false">Yearly</button>
    </div>

    <ul class="plans">
      <li class="plan">
        <h3>Starter</h3>
        <p class="price"><span data-price="starter">$9</span> <span class="per" data-per>per month</span></p>
        <p class="save" data-save="starter"></p>
        <ul>
          <li>5 clients</li>
          <li>PDF invoices</li>
        </ul>
      </li>
      <li class="plan featured">
        <p class="tag">Most popular</p>
        <h3>Pro</h3>
        <p class="price"><span data-price="pro">$19</span> <span class="per" data-per>per month</span></p>
        <p class="save" data-save="pro"></p>
        <ul>
          <li>Unlimited clients</li>
          <li>Automatic reminders</li>
          <li>Your own logo</li>
        </ul>
      </li>
      <li class="plan">
        <h3>Team</h3>
        <p class="price"><span data-price="team">$49</span> <span class="per" data-per>per month</span></p>
        <p class="save" data-save="team"></p>
        <ul>
          <li>Everything in Pro</li>
          <li>5 seats</li>
          <li>A shared client list</li>
        </ul>
      </li>
    </ul>
  </div>
</section>

<section aria-labelledby="faq-h">
  <div class="wrap">
    <h2 id="faq-h">Questions</h2>

    <details>
      <summary>Can I cancel any time?</summary>
      <p>Yes. Cancel from your account page and you won't be charged again. Nothing is locked in.</p>
    </details>
    <details>
      <summary>Do my clients need an account?</summary>
      <p>No. They open a link, see the invoice and pay. They never have to sign up.</p>
    </details>
    <details>
      <summary>Which currencies can I invoice in?</summary>
      <p>You can invoice in 30 currencies, and each invoice shows the amount in your client's currency.</p>
    </details>
    <details>
      <summary>What happens when the free trial ends?</summary>
      <p>You pick a plan. If you don't, your account switches to view-only and your invoices stay safe for 90 days.</p>
    </details>
  </div>
</section>

<section id="signup" aria-labelledby="signup-h">
  <div class="wrap">
    <h2 id="signup-h">Ready when you are</h2>
    <p class="muted">Set up your first invoice in about five minutes.</p>
    <a class="btn" href="#signup">Start free trial</a>
  </div>
</section>

<footer>
  <div class="wrap">
    <p>Brindlebill — invoicing for freelancers.</p>
  </div>
</footer>

<script>
(function () {
  var monthly = { starter: 9, pro: 19, team: 49 };
  var monthlyBtn = document.getElementById('btn-monthly');
  var yearlyBtn = document.getElementById('btn-yearly');
  var prices = document.querySelectorAll('[data-price]');
  var pers = document.querySelectorAll('[data-per]');
  var saves = document.querySelectorAll('[data-save]');

  function render(period) {
    var yearly = period === 'yearly';

    prices.forEach(function (el) {
      var m = monthly[el.getAttribute('data-price')];
      el.textContent = '$' + (yearly ? m * 10 : m);
    });

    pers.forEach(function (el) {
      el.textContent = yearly ? 'per year' : 'per month';
    });

    saves.forEach(function (el) {
      var m = monthly[el.getAttribute('data-save')];
      el.textContent = yearly ? 'Two months free — you save $' + (m * 2) + ' a year.' : '';
    });

    monthlyBtn.setAttribute('aria-pressed', String(!yearly));
    yearlyBtn.setAttribute('aria-pressed', String(yearly));
  }

  monthlyBtn.addEventListener('click', function () { render('monthly'); });
  yearlyBtn.addEventListener('click', function () { render('yearly'); });

  render('monthly');
})();
</script>

</body>
</html>

A few notes on the choices:

  • Yearly maths: two months free means ten months' money for twelve, so Starter $90, Pro $190, Team $490 — shown as the whole-year figure, with a line underneath saying what you save.
  • Mobile: base text is 17px, the smallest text anywhere is 16px, buttons and FAQ rows are at least 44–48px tall, and the layout is one column until 640px wide.
  • Headings: one h1 for the headline, h2 for each section, h3 for feature and plan names — no levels skipped.
  • FAQ uses native <details>, so it opens and closes even with JavaScript off. The pricing switch needs JavaScript; monthly prices are in the HTML, so that's what shows if the script doesn't run.
  • The two "Start free trial" buttons currently point at the sign-up section — swap the href for your real sign-up URL when it's ready.
  • Floor2/2, 100% passed
  • Middle4/4, 100% passed
  • Top3/3, 100% passed
9 of 9 checks passedfloor 2/2, middle 4/4, top 3/3
  • Passed.
    The page loads with no errors in the console
    Floor Page test
  • Passed.
    Every section is there, with the exact plan names, prices and text
    Floor Page test
  • Passed.
    No sideways scroll on a 375 px phone
    Middle Page test
  • Passed.
    Nothing is loaded from outside the file
    Middle Page test
  • Passed.
    One h1, no skipped heading levels, text 16 px or larger, enough contrast
    Middle Page test
  • Passed.
    The page looks finished: clear hierarchy, pricing easy to compare, nothing broken
    Middle Read
  • Passed.
    Yearly shows $90, $190 and $490, and Monthly brings back $9, $19, $49
    Top Page test
  • Passed.
    Each FAQ question opens and closes from the keyboard
    Top Page test
  • Passed.
    Still no sideways scroll on a phone with Yearly on and every FAQ open
    Top Page test
  • Took 43 s.
  • First words after 3.2 s.
  • Wrote 4,355 tokens, 51 of them thinking.
  • 12¢ at API prices.
All numbers.
Time to finish43 s
Time to first words3.2 s
Input tokens2
Cached tokens1,265
Output tokens4,355
Thinking tokens51
Words in the answer1,166
Price at API rates12¢

AI answer from Opus 5 · max effort · 1 Oct 2026

8 of 9

Screenshot of the page built in this answer, at a desktop width of 1280 pixels
Our screenshot, 1280 by 800. Open it for the full length.
Screenshot of the page built in this answer, at a phone width of 375 pixels
Phone, 375 by 812.
More screenshots (2).
Screenshot of the page built in this answer, at a desktop width of 1280 pixelsScreenshot of the page built in this answer, at a phone width of 375 pixels
Show the code and the answer.

Here's the complete file — save it as index.html and open it in a browser.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Brindlebill — invoicing for freelancers</title>
<meta name="description" content="Brindlebill is a small invoicing app for freelancers. Send an invoice in a minute, let reminders do the chasing, and take card and bank payments.">
<style>
  /* ---------- tokens ---------- */
  :root{
    color-scheme: light dark;
    --bg:#fbfaf8;
    --surface:#ffffff;
    --surface-2:#f3efe9;
    --ink:#1b1a19;
    --muted:#55514c;
    --line:#e4ded5;
    --brand:#9c4a1f;
    --brand-ink:#ffffff;
    --accent:#1f5c54;
    --focus:#1f5c54;
    --good:#1f5c54;
    --bad:#a32218;
    --radius:14px;
    --shadow:0 1px 2px rgba(27,26,25,.06), 0 12px 32px rgba(27,26,25,.07);
  }
  @media (prefers-color-scheme: dark){
    :root{
      --bg:#141311;
      --surface:#1d1b18;
      --surface-2:#232120;
      --ink:#f4f1ec;
      --muted:#c2bab0;
      --line:#352f2a;
      --brand:#e08a4e;
      --brand-ink:#241406;
      --accent:#7fd1c2;
      --focus:#7fd1c2;
      --good:#7fd1c2;
      --bad:#ff9b8f;
      --shadow:0 1px 2px rgba(0,0,0,.4), 0 12px 32px rgba(0,0,0,.35);
    }
  }

  /* ---------- base ---------- */
  *,*::before,*::after{box-sizing:border-box}
  html{scroll-behavior:smooth; scroll-padding-top:5.5rem}
  body{
    margin:0;
    background:var(--bg);
    color:var(--ink);
    font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    font-size:1.0625rem;            /* 17px floor for body text */
    line-height:1.6;
    -webkit-text-size-adjust:100%;
  }
  img,svg{max-width:100%}
  h1,h2,h3{line-height:1.2; margin:0 0 .5rem; letter-spacing:-.015em}
  h1{font-size:clamp(2.05rem, 6.5vw, 3.3rem)}
  h2{font-size:clamp(1.55rem, 4.4vw, 2.15rem)}
  h3{font-size:1.1875rem}
  p{margin:0 0 1rem}
  a{color:var(--brand)}
  a:hover{text-decoration-thickness:2px}
  :focus-visible{outline:3px solid var(--focus); outline-offset:3px; border-radius:6px}
  .sr-only{
    position:absolute;width:1px;height:1px;padding:0;margin:-1px;
    overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0;
  }
  .wrap{width:100%;max-width:1080px;margin:0 auto;padding:0 clamp(1rem,4.5vw,2rem)}
  .wrap.narrow{max-width:720px}
  .section{padding:clamp(3rem,8vw,5.5rem) 0}
  .section.alt{background:var(--surface-2);border-block:1px solid var(--line)}
  .section-lede{color:var(--muted);max-width:52ch;margin-bottom:1.75rem}
  .fineprint{color:var(--muted);font-size:1rem;margin:0}

  .skip{
    position:absolute;left:-9999px;top:0;background:var(--ink);color:var(--bg);
    padding:.75rem 1rem;border-radius:0 0 10px 0;z-index:50
  }
  .skip:focus{left:0}

  /* ---------- buttons ---------- */
  .btn{
    display:inline-flex;align-items:center;justify-content:center;
    min-height:48px;padding:.7rem 1.35rem;
    font:inherit;font-weight:650;font-size:1.0625rem;
    border-radius:999px;border:2px solid transparent;
    text-decoration:none;cursor:pointer;
    transition:transform .15s ease, background-color .15s ease, border-color .15s ease;
  }
  .btn:active{transform:translateY(1px)}
  .btn-primary{background:var(--brand);color:var(--brand-ink)}
  .btn-primary:hover{background:color-mix(in srgb, var(--brand) 86%, #000)}
  .btn-quiet{background:var(--surface);color:var(--ink);border-color:var(--line)}
  .btn-quiet:hover{border-color:var(--ink)}
  .cta-row{display:flex;flex-wrap:wrap;gap:.75rem;margin:1.75rem 0 1rem}

  /* ---------- header ---------- */
  .site-header{
    position:sticky;top:0;z-index:20;
    background:var(--bg);border-bottom:1px solid var(--line);
  }
  .site-header .wrap{
    display:flex;align-items:center;gap:1rem;flex-wrap:wrap;
    min-height:68px;padding-block:.6rem
  }
  .brand{display:flex;align-items:center;gap:.55rem;font-weight:750;font-size:1.125rem;letter-spacing:-.02em}
  .brand .mark{
    inline-size:1.6rem;block-size:1.6rem;border-radius:7px;flex:none;
    background:
      repeating-linear-gradient(115deg, var(--brand) 0 4px, transparent 4px 9px),
      var(--accent);
  }
  .site-nav{margin-left:auto;display:flex;align-items:center;gap:.35rem 1.1rem;flex-wrap:wrap}
  .site-nav a{color:var(--ink);text-decoration:none;font-weight:600;padding:.5rem .25rem}
  .site-nav a:hover{text-decoration:underline}
  .site-nav .btn{padding:.5rem 1.1rem;min-height:44px}
  @media (max-width:33em){ .site-nav .nav-link{display:none} }

  /* ---------- hero ---------- */
  .hero{padding:clamp(2.5rem,8vw,5rem) 0 clamp(2.5rem,7vw,4rem)}
  .hero-grid{display:grid;gap:clamp(2rem,5vw,3rem);align-items:center}
  @media (min-width:62em){ .hero-grid{grid-template-columns:1.05fr .95fr} }
  .eyebrow{
    display:inline-block;margin:0 0 1rem;
    font-size:1rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
    color:var(--accent)
  }
  .lede{font-size:clamp(1.125rem,2.6vw,1.3rem);color:var(--muted);max-width:46ch}

  /* decorative invoice preview, drawn with CSS only */
  .mock{
    background:var(--surface);border:1px solid var(--line);border-radius:18px;
    box-shadow:var(--shadow);padding:1.25rem;max-width:27rem
  }
  .mock-top{display:flex;justify-content:space-between;align-items:baseline;gap:1rem;margin-bottom:.85rem}
  .mock-top strong{font-size:1.0625rem}
  .mock-top span{color:var(--muted);font-size:1rem}
  .mock-row{display:flex;justify-content:space-between;gap:1rem;padding:.65rem 0;border-top:1px dashed var(--line)}
  .mock-row.total{border-top:2px solid var(--line);font-weight:700}
  .mock-pay{
    margin-top:1rem;display:flex;align-items:center;justify-content:center;
    min-height:46px;border-radius:999px;background:var(--accent);color:var(--bg);font-weight:650
  }
  @media (prefers-color-scheme: dark){ .mock-pay{color:#0f1a18} }

  /* ---------- cards ---------- */
  .card{
    background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
    padding:1.35rem;box-shadow:var(--shadow)
  }
  .grid{display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(16rem,1fr));list-style:none;margin:0;padding:0}
  .feature p{margin:0;color:var(--muted)}
  .ficon{
    display:block;inline-size:2.4rem;block-size:2.4rem;border-radius:10px;margin-bottom:.9rem;
    background:color-mix(in srgb, var(--brand) 16%, transparent);position:relative
  }
  .ficon::after{content:"";position:absolute;inset:0;margin:auto;background:var(--brand)}
  .f1 .ficon::after{inline-size:1.05rem;block-size:2px;box-shadow:0 -5px 0 var(--brand), 0 5px 0 var(--brand)}
  .f2 .ficon::after{inline-size:.95rem;block-size:.95rem;border-radius:50%;background:none;border:2px solid var(--brand)}
  .f3 .ficon::after{inline-size:1.25rem;block-size:.85rem;border-radius:3px;border:2px solid var(--brand);background:none}

  /* ---------- pricing ---------- */
  .billing{border:0;margin:0 0 .5rem;padding:0}
  .seg{
    position:relative;display:inline-flex;gap:4px;padding:4px;
    background:var(--surface);border:1px solid var(--line);border-radius:999px
  }
  .seg input{position:absolute;opacity:0;width:1px;height:1px}
  .seg label{
    display:inline-flex;align-items:center;justify-content:center;
    min-height:46px;padding:0 1.25rem;border-radius:999px;
    font-weight:650;cursor:pointer;color:var(--ink)
  }
  .seg input:checked + label{background:var(--ink);color:var(--bg)}
  .seg input:focus-visible + label{outline:3px solid var(--focus);outline-offset:3px}
  .billing-hint{color:var(--muted);font-size:1rem;margin:.85rem 0 1.75rem}

  .plan{display:flex;flex-direction:column}
  .plan.featured{border:2px solid var(--brand)}
  .badge{
    align-self:flex-start;margin:0 0 .6rem;padding:.25rem .7rem;border-radius:999px;
    background:color-mix(in srgb, var(--brand) 16%, transparent);
    color:var(--brand);font-size:1rem;font-weight:700;letter-spacing:.02em
  }
  .price{display:flex;align-items:baseline;flex-wrap:wrap;gap:.45rem;margin:.35rem 0 .35rem}
  .amount{font-size:clamp(2.1rem,5.5vw,2.6rem);font-weight:750;letter-spacing:-.03em}
  .unit{color:var(--muted);font-size:1rem}
  .note{color:var(--muted);font-size:1rem;margin:0 0 1.1rem;min-height:3rem}
  .ticks{list-style:none;margin:0 0 1.5rem;padding:0;display:grid;gap:.6rem}
  .ticks li{position:relative;padding-left:1.9rem}
  .ticks li::before{
    content:"";position:absolute;left:.3rem;top:.5em;
    inline-size:.66rem;block-size:.36rem;
    border-left:2px solid var(--accent);border-bottom:2px solid var(--accent);
    transform:rotate(-45deg)
  }
  .plan .btn{margin-top:auto;width:100%}

  /* ---------- FAQ ---------- */
  .faq details{
    background:var(--surface);border:1px solid var(--line);
    border-radius:var(--radius);margin-bottom:.75rem
  }
  .faq summary{
    display:flex;align-items:center;justify-content:space-between;gap:1rem;
    min-height:58px;padding:1rem 1.15rem;cursor:pointer;list-style:none
  }
  .faq summary::-webkit-details-marker{display:none}
  .faq summary h3{margin:0;font-size:1.0625rem}
  .faq summary::after{
    content:"";flex:none;inline-size:15px;block-size:15px;
    background:
      linear-gradient(var(--brand),var(--brand)) center/15px 2px no-repeat,
      linear-gradient(var(--brand),var(--brand)) center/2px 15px no-repeat;
    transition:transform .18s ease
  }
  .faq details[open] summary::after{transform:rotate(45deg)}
  .faq .answer{padding:0 1.15rem 1.15rem}
  .faq .answer p{margin:0;color:var(--muted)}

  /* ---------- sign up ---------- */
  .signup{display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end;margin-top:1.5rem}
  .field{display:flex;flex-direction:column;gap:.35rem;flex:1 1 17rem}
  .field label{font-weight:650;font-size:1rem}
  .field input{
    font:inherit;font-size:1.0625rem;        /* 17px: stops iOS zooming on focus */
    min-height:48px;padding:.6rem .9rem;
    color:var(--ink);background:var(--surface);
    border:1px solid var(--line);border-radius:10px
  }
  .signup .btn{flex:0 0 auto}
  .form-msg{margin:.9rem 0 0;font-weight:600}
  .form-msg.good{color:var(--good)}
  .form-msg.bad{color:var(--bad)}

  /* ---------- footer ---------- */
  .site-footer{border-top:1px solid var(--line);padding:2rem 0;color:var(--muted)}
  .site-footer .wrap{display:flex;flex-wrap:wrap;gap:1rem 1.5rem;align-items:center}
  .site-footer p{margin:0}
  .site-footer nav{margin-left:auto;display:flex;gap:1.25rem;flex-wrap:wrap}
  .site-footer a{color:var(--muted)}

  @media (prefers-reduced-motion: reduce){
    html{scroll-behavior:auto}
    *{animation-duration:.01ms !important;animation-iteration-count:1 !important;transition-duration:.01ms !important}
  }
</style>
</head>
<body>

<a class="skip" href="#main">Skip to content</a>

<header class="site-header">
  <div class="wrap">
    <p class="brand"><span class="mark" aria-hidden="true"></span>Brindlebill</p>
    <nav class="site-nav" aria-label="Page sections">
      <a class="nav-link" href="#features">Features</a>
      <a class="nav-link" href="#pricing">Pricing</a>
      <a class="nav-link" href="#faq">FAQ</a>
      <a class="btn btn-primary" href="#start">Start free trial</a>
    </nav>
  </div>
</header>

<main id="main">

  <!-- Hero -->
  <section class="hero">
    <div class="wrap hero-grid">
      <div>
        <p class="eyebrow">Invoicing for freelancers</p>
        <h1>Send the invoice, then get back to work.</h1>
        <p class="lede">Brindlebill writes a clean invoice in a minute, chases it politely for you, and takes card or bank payments.</p>
        <p class="cta-row">
          <a class="btn btn-primary" href="#start">Start free trial</a>
          <a class="btn btn-quiet" href="#pricing">See pricing</a>
        </p>
        <p class="fineprint">14 days free. No card needed.</p>
      </div>

      <div class="mock" aria-hidden="true">
        <div class="mock-top">
          <strong>Invoice 0041</strong>
          <span>Due in 14 days</span>
        </div>
        <div class="mock-row"><span>Brand sprint — 3 days</span><span>$1,800.00</span></div>
        <div class="mock-row"><span>Illustrations — 6 spots</span><span>$420.00</span></div>
        <div class="mock-row total"><span>Total due</span><span>$2,220.00</span></div>
        <div class="mock-pay">Pay this invoice</div>
      </div>
    </div>
  </section>

  <!-- Features -->
  <section class="section" id="features">
    <div class="wrap">
      <h2>Three things it does well</h2>
      <p class="section-lede">No modules, no add-ons, no setup weekend. Just the parts of invoicing you actually touch.</p>
      <ul class="grid">
        <li class="card feature f1">
          <span class="ficon" aria-hidden="true"></span>
          <h3>Invoices in a minute</h3>
          <p>Pick a client, add a couple of lines and send — your rates, tax and invoice numbers are already filled in.</p>
        </li>
        <li class="card feature f2">
          <span class="ficon" aria-hidden="true"></span>
          <h3>Gentle reminders</h3>
          <p>Polite follow-ups go out on the schedule you choose and stop the moment an invoice is paid.</p>
        </li>
        <li class="card feature f3">
          <span class="ficon" aria-hidden="true"></span>
          <h3>Card and bank payments</h3>
          <p>Clients pay by card or bank transfer straight from the invoice, with the fees shown before they confirm.</p>
        </li>
      </ul>
    </div>
  </section>

  <!-- Pricing -->
  <section class="section alt" id="pricing">
    <div class="wrap">
      <h2>Simple pricing</h2>
      <p class="section-lede">Every plan includes unlimited invoices and a 14-day free trial.</p>

      <fieldset class="billing">
        <legend class="sr-only">Billing period</legend>
        <div class="seg">
          <input type="radio" name="billing" id="pay-monthly" value="monthly" checked>
          <label for="pay-monthly">Monthly</label>
          <input type="radio" name="billing" id="pay-yearly" value="yearly">
          <label for="pay-yearly">Yearly</label>
        </div>
        <p class="billing-hint">Pay yearly and two months are free. Yearly prices below are for the whole year.</p>
      </fieldset>
      <p class="sr-only" id="billing-status" role="status"></p>

      <ul class="grid">
        <li class="card plan" data-monthly="9" data-yearly="90">
          <h3>Starter</h3>
          <p class="price"><span class="amount">$9</span> <span class="unit">per month</span></p>
          <p class="note">Billed monthly. Cancel any time.</p>
          <ul class="ticks">
            <li>5 clients</li>
            <li>PDF invoices</li>
          </ul>
          <a class="btn btn-quiet" href="#start" aria-label="Start free trial on the Starter plan">Start free trial</a>
        </li>

        <li class="card plan featured" data-monthly="19" data-yearly="190">
          <p class="badge">Most popular</p>
          <h3>Pro</h3>
          <p class="price"><span class="amount">$19</span> <span class="unit">per month</span></p>
          <p class="note">Billed monthly. Cancel any time.</p>
          <ul class="ticks">
            <li>Unlimited clients</li>
            <li>Automatic reminders</li>
            <li>Your own logo</li>
          </ul>
          <a class="btn btn-primary" href="#start" aria-label="Start free trial on the Pro plan">Start free trial</a>
        </li>

        <li class="card plan" data-monthly="49" data-yearly="490">
          <h3>Team</h3>
          <p class="price"><span class="amount">$49</span> <span class="unit">per month</span></p>
          <p class="note">Billed monthly. Cancel any time.</p>
          <ul class="ticks">
            <li>Everything in Pro</li>
            <li>5 seats</li>
            <li>A shared client list</li>
          </ul>
          <a class="btn btn-quiet" href="#start" aria-label="Start free trial on the Team plan">Start free trial</a>
        </li>
      </ul>
    </div>
  </section>

  <!-- FAQ -->
  <section class="section" id="faq">
    <div class="wrap narrow">
      <h2>Questions</h2>
      <div class="faq">
        <details>
          <summary><h3>Can I cancel any time?</h3></summary>
          <div class="answer"><p>Yes. Cancel from your account page and you won’t be charged again. Nothing is locked in.</p></div>
        </details>
        <details>
          <summary><h3>Do my clients need an account?</h3></summary>
          <div class="answer"><p>No. They open a link, see the invoice and pay. They never have to sign up.</p></div>
        </details>
        <details>
          <summary><h3>Which currencies can I invoice in?</h3></summary>
          <div class="answer"><p>You can invoice in 30 currencies, and each invoice shows the amount in your client’s currency.</p></div>
        </details>
        <details>
          <summary><h3>What happens when the free trial ends?</h3></summary>
          <div class="answer"><p>You pick a plan. If you don’t, your account switches to view-only and your invoices stay safe for 90 days.</p></div>
        </details>
      </div>
    </div>
  </section>

  <!-- Sign up -->
  <section class="section alt" id="start">
    <div class="wrap narrow">
      <h2>Try Brindlebill free for 14 days</h2>
      <p class="section-lede">Add your details, send your first invoice, and see how it feels. No card needed to start.</p>
      <form class="signup" id="signup" novalidate>
        <span class="field">
          <label for="email">Your email</label>
          <input id="email" name="email" type="email" autocomplete="email" inputmode="email" placeholder="you@example.com" required>
        </span>
        <button class="btn btn-primary" type="submit">Start free trial</button>
      </form>
      <p class="form-msg" id="form-msg" role="status" hidden></p>
    </div>
  </section>

</main>

<footer class="site-footer">
  <div class="wrap">
    <p>Brindlebill — invoicing for freelancers.</p>
    <nav aria-label="Footer">
      <a href="#features">Features</a>
      <a href="#pricing">Pricing</a>
      <a href="#faq">FAQ</a>
    </nav>
  </div>
</footer>

<script>
(function () {
  'use strict';

  /* ---- Monthly / Yearly pricing switch ---- */
  var radios = document.querySelectorAll('input[name="billing"]');
  var plans  = document.querySelectorAll('.plan');
  var status = document.getElementById('billing-status');

  function money(n) { return '$' + n.toLocaleString('en-US'); }

  function render(period) {
    var yearly = period === 'yearly';

    plans.forEach(function (plan) {
      var monthly   = Number(plan.dataset.monthly);
      var yearPrice = Number(plan.dataset.yearly);   // 10 x monthly, i.e. two months free
      var saving    = monthly * 12 - yearPrice;

      plan.querySelector('.amount').textContent = money(yearly ? yearPrice : monthly);
      plan.querySelector('.unit').textContent   = yearly ? 'per year' : 'per month';
      plan.querySelector('.note').textContent   = yearly
        ? 'Billed once a year. Two months free — you save ' + money(saving) + '.'
        : 'Billed monthly. Cancel any time.';
    });

    status.textContent = yearly
      ? 'Showing yearly prices for the whole year, with two months free.'
      : 'Showing monthly prices.';
  }

  radios.forEach(function (radio) {
    radio.addEventListener('change', function () {
      if (radio.checked) { render(radio.value); }
    });
  });

  var checked = document.querySelector('input[name="billing"]:checked');
  render(checked ? checked.value : 'monthly');

  /* ---- Sign-up form (front end only: wire this to your backend later) ---- */
  var form  = document.getElementById('signup');
  var email = document.getElementById('email');
  var msg   = document.getElementById('form-msg');

  form.addEventListener('submit', function (event) {
    event.preventDefault();

    var value = email.value.trim();
    var looksLikeEmail = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value);

    msg.hidden = false;

    if (!looksLikeEmail) {
      msg.className = 'form-msg bad';
      msg.textContent = 'That email doesn’t look right. Try something like you@example.com.';
      email.focus();
      return;
    }

    msg.className = 'form-msg good';
    msg.textContent = 'Thanks — we’ve sent a sign-up link to ' + value + '.';
    form.reset();
  });
})();
</script>

</body>
</html>

A few things worth knowing:

  • Yearly maths lives in the data-yearly attributes (90, 190, 490 = ten months' worth), and the saving line is calculated from them, so if you change a price you only edit the two numbers on that plan.
  • Mobile: body text is 17px, the email input is 17px so iOS won't zoom on focus, all buttons and the FAQ rows are at least 46–58px tall, and the feature/plan grids collapse to one column on narrow screens.
  • Headings: one <h1> (the headline), <h2> per section, <h3> for each feature, plan and FAQ question. The FAQ uses native <details>/<summary>, so it opens and closes by keyboard and still works if JavaScript fails.
  • Decoration is all CSS — the invoice preview, logo mark, feature icons, ticks and the FAQ plus/cross. No fonts, images or libraries are fetched.
  • The sign-up form just validates and shows a message; point it at your real endpoint (or swap the handler for a normal action/method) when the backend exists.
  • Floor2/2, 100% passed
  • Middle3/4, 75% passed
  • Top3/3, 100% passed
8 of 9 checks passedfloor 2/2, middle 3/4, top 3/3
  • Passed.
    The page loads with no errors in the console
    Floor Page test
  • Passed.
    Every section is there, with the exact plan names, prices and text
    Floor Page test
  • Passed.
    No sideways scroll on a 375 px phone
    Middle Page test
  • Passed.
    Nothing is loaded from outside the file
    Middle Page test
  • Missed.
    One h1, no skipped heading levels, text 16 px or larger, enough contrasttest group failed
    Middle Page test
  • Passed.
    The page looks finished: clear hierarchy, pricing easy to compare, nothing broken
    Middle Read
  • Passed.
    Yearly shows $90, $190 and $490, and Monthly brings back $9, $19, $49
    Top Page test
  • Passed.
    Each FAQ question opens and closes from the keyboard
    Top Page test
  • Passed.
    Still no sideways scroll on a phone with Yearly on and every FAQ open
    Top Page test
  • Took 3 min 47 s.
  • First words after 2 min 26 s.
  • Wrote 21,198 tokens, 11,829 of them thinking.
  • 54¢ at API prices.
All numbers.
Time to finish3 min 47 s
Time to first words2 min 26 s
Input tokens2
Cached tokens1,265
Output tokens21,198
Thinking tokens11,829
Words in the answer1,659
Price at API rates54¢
B02Build a dashboard from a picture of its designLow 6/7Max 7/7Max +1

What it asks for. Sends a picture of a designer's mockup, an admin dashboard for a small bike repair shop, and asks for it to be built as one HTML file that matches the layout, colours, spacing and text as closely as it can. The bar chart has to be drawn to scale. On a phone the page must stack into one column, hide the sidebar behind a menu button and never scroll sideways.

Max effort passed 1 more check than low.

AI answer from Opus 5 · low effort · 1 Oct 2026

6 of 7

Screenshot of the page built in this answer, at a desktop width of 1280 pixels
Our screenshot, 1280 by 800. Open it for the full length.
Screenshot of the page built in this answer, at a phone width of 375 pixels
Phone, 375 by 812.
More screenshots (1).
Screenshot of the page built in this answer, at a phone width of 375 pixels
Show the code and the answer.

Here's the complete single-file build:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Overview — Tarnwick Bikeworks</title>
<style>
  :root{
    --navy:#172338;
    --navy-dark:#101a2b;
    --blue:#3b76e8;
    --page:#f4f6fa;
    --card:#ffffff;
    --border:#e6e9f0;
    --text:#1b2436;
    --muted:#6b7488;
    --green:#15924f;
    --red:#d9453a;
    --sidebar-w:240px;
  }
  *{box-sizing:border-box;}
  html,body{margin:0;padding:0;}
  body{
    background:var(--page);
    color:var(--text);
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
    font-size:14px;
    line-height:1.4;
    -webkit-font-smoothing:antialiased;
    overflow-x:hidden;
  }

  /* ---------- Sidebar ---------- */
  .sidebar{
    position:fixed;
    top:0; left:0; bottom:0;
    width:var(--sidebar-w);
    background:var(--navy);
    padding:0 12px 24px;
    z-index:40;
    overflow-y:auto;
  }
  .brand{
    color:#fff;
    font-size:15px;
    font-weight:700;
    letter-spacing:.2px;
    padding:22px 12px;
    border-bottom:1px solid rgba(255,255,255,.08);
    margin:0 -12px 16px;
    padding-left:24px;
  }
  .nav{list-style:none;margin:0;padding:0;}
  .nav li{margin-bottom:4px;}
  .nav a{
    display:flex;
    align-items:center;
    gap:12px;
    padding:10px 12px;
    border-radius:8px;
    color:#aeb7c7;
    text-decoration:none;
    font-size:14px;
    font-weight:500;
  }
  .nav a:hover{background:rgba(255,255,255,.06);color:#e7ebf2;}
  .nav a.active{background:var(--blue);color:#fff;font-weight:600;}
  .nav .box{
    width:15px;height:15px;
    border:2px solid currentColor;
    border-radius:4px;
    flex:0 0 auto;
    opacity:.9;
  }

  /* ---------- Topbar ---------- */
  .main{margin-left:var(--sidebar-w);min-width:0;}
  .topbar{
    background:#fff;
    border-bottom:1px solid var(--border);
    height:64px;
    display:flex;
    align-items:center;
    gap:16px;
    padding:0 24px;
  }
  .page-title{font-size:19px;font-weight:700;margin:0;}
  .search{
    position:relative;
    margin-left:auto;
    width:290px;
    max-width:100%;
  }
  .search input{
    width:100%;
    height:34px;
    border:1px solid var(--border);
    background:#f7f8fb;
    border-radius:8px;
    padding:0 12px 0 34px;
    font-size:13px;
    color:var(--text);
    font-family:inherit;
    outline:none;
  }
  .search input::placeholder{color:#9aa2b2;}
  .search input:focus{border-color:#c3d0ea;background:#fff;}
  .search svg{
    position:absolute;
    left:11px; top:50%;
    transform:translateY(-50%);
    width:14px;height:14px;
    stroke:#9aa2b2;fill:none;stroke-width:2;
  }
  .avatar{
    width:34px;height:34px;
    border-radius:50%;
    background:var(--navy);
    color:#fff;
    font-size:12px;
    font-weight:700;
    display:flex;align-items:center;justify-content:center;
    flex:0 0 auto;
  }
  .menu-btn{
    display:none;
    width:36px;height:36px;
    border:1px solid var(--border);
    background:#fff;
    border-radius:8px;
    padding:0;
    cursor:pointer;
    align-items:center;justify-content:center;
    flex:0 0 auto;
  }
  .menu-btn span{
    display:block;width:16px;height:2px;
    background:var(--text);border-radius:2px;
    position:relative;
  }
  .menu-btn span::before,.menu-btn span::after{
    content:"";position:absolute;left:0;
    width:16px;height:2px;background:var(--text);border-radius:2px;
  }
  .menu-btn span::before{top:-5px;}
  .menu-btn span::after{top:5px;}

  /* ---------- Content ---------- */
  .content{padding:24px;display:flex;flex-direction:column;gap:24px;}
  .card{
    background:var(--card);
    border:1px solid var(--border);
    border-radius:10px;
  }

  .stats{
    display:grid;
    grid-template-columns:repeat(3,1fr);
    gap:32px;
  }
  .stat{padding:18px 20px 16px;}
  .stat .label{color:var(--muted);font-size:13px;}
  .stat .value{font-size:30px;font-weight:700;letter-spacing:-.5px;margin:6px 0 8px;}
  .delta{font-size:12px;font-weight:600;display:flex;align-items:center;gap:5px;}
  .delta.up{color:var(--green);}
  .delta.down{color:var(--red);}
  .delta .arrow{font-size:9px;line-height:1;}

  /* ---------- Chart ---------- */
  .chart-head{
    display:flex;align-items:center;justify-content:space-between;
    padding:18px 22px 0;
  }
  .chart-head h2{font-size:15px;font-weight:700;margin:0;}
  .chart-head .note{font-size:12px;color:var(--muted);}
  .chart{
    display:flex;
    align-items:flex-end;
    gap:4px;
    padding:28px 22px 12px;
    height:210px;
    border-bottom:1px solid var(--border);
    margin:0 22px;
  }
  .col{
    flex:1 1 0;
    min-width:0;
    display:flex;
    flex-direction:column;
    justify-content:flex-end;
    align-items:center;
    height:100%;
  }
  .col .n{font-size:12px;font-weight:700;margin-bottom:6px;}
  .bar{
    width:60px;
    max-width:70%;
    background:var(--blue);
    border-radius:2px;
  }
  .xaxis{
    display:flex;gap:4px;
    padding:8px 22px 20px;
  }
  .xaxis div{flex:1 1 0;text-align:center;font-size:12px;color:var(--muted);}

  /* ---------- Table ---------- */
  .table-head{padding:18px 22px 14px;}
  .table-head h2{font-size:15px;font-weight:700;margin:0;}
  table{width:100%;border-collapse:collapse;}
  thead th{
    background:#f7f8fb;
    border-top:1px solid var(--border);
    border-bottom:1px solid var(--border);
    text-align:left;
    font-size:12px;
    font-weight:600;
    color:var(--muted);
    padding:9px 12px;
    white-space:nowrap;
  }
  tbody td{
    border-bottom:1px solid var(--border);
    padding:13px 12px;
    font-size:13px;
    vertical-align:middle;
  }
  tbody tr:last-child td{border-bottom:0;}
  thead th:first-child,tbody td:first-child{padding-left:22px;}
  thead th:last-child,tbody td:last-child{padding-right:22px;}
  .job-id{font-weight:700;}
  .pill{
    display:inline-block;
    padding:4px 11px;
    border-radius:999px;
    font-size:12px;
    font-weight:600;
    white-space:nowrap;
  }
  .pill.ready{background:#d7f3e2;color:#16693f;}
  .pill.progress{background:#fdeecd;color:#8a5c10;}
  .pill.parts{background:#e5dcfb;color:#5a3fb0;}

  .table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;}
  .scrim{display:none;}

  /* ---------- Mobile ---------- */
  @media (max-width:860px){
    .stats{grid-template-columns:1fr;gap:16px;}
  }
  @media (max-width:760px){
    .sidebar{
      transform:translateX(-100%);
      transition:transform .22s ease;
      width:250px;
      box-shadow:0 0 40px rgba(0,0,0,.25);
    }
    body.nav-open .sidebar{transform:translateX(0);}
    body.nav-open{overflow:hidden;}
    .scrim{
      display:block;
      position:fixed;inset:0;
      background:rgba(14,20,33,.45);
      opacity:0;pointer-events:none;
      transition:opacity .22s ease;
      z-index:30;
    }
    body.nav-open .scrim{opacity:1;pointer-events:auto;}
    .main{margin-left:0;}
    .menu-btn{display:flex;}
    .topbar{padding:0 14px;gap:12px;height:60px;}
    .page-title{font-size:17px;}
    .search{
      order:4;
      width:100%;
      margin-left:0;
      padding-bottom:0;
    }
    .topbar{flex-wrap:wrap;height:auto;padding:12px 14px;}
    .search{margin-top:10px;}
    .content{padding:16px;gap:16px;}
    .stat .value{font-size:27px;}
    .chart{padding:24px 12px 12px;margin:0 12px;height:190px;}
    .xaxis{padding:8px 12px 18px;}
    .bar{width:100%;}
    .chart-head{padding:16px 14px 0;}
    .table-head,thead th:first-child,tbody td:first-child{padding-left:14px;}
    thead th:last-child,tbody td:last-child{padding-right:14px;}
  }
</style>
</head>
<body>

<aside class="sidebar" id="sidebar">
  <div class="brand">Tarnwick Bikeworks</div>
  <nav>
    <ul class="nav">
      <li><a href="#" class="active"><span class="box"></span>Overview</a></li>
      <li><a href="#"><span class="box"></span>Jobs</a></li>
      <li><a href="#"><span class="box"></span>Bookings</a></li>
      <li><a href="#"><span class="box"></span>Stock</a></li>
      <li><a href="#"><span class="box"></span>Reports</a></li>
    </ul>
  </nav>
</aside>

<div class="scrim" id="scrim"></div>

<div class="main">
  <header class="topbar">
    <button class="menu-btn" id="menuBtn" aria-label="Open menu" aria-expanded="false"><span></span></button>
    <h1 class="page-title">Overview</h1>
    <div class="search">
      <svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/></svg>
      <input type="text" placeholder="Search jobs and bikes" aria-label="Search jobs and bikes">
    </div>
    <div class="avatar">TB</div>
  </header>

  <main class="content">

    <section class="stats">
      <div class="card stat">
        <div class="label">Revenue</div>
        <div class="value">$8,460</div>
        <div class="delta up"><span class="arrow">▲</span>12.4% vs last week</div>
      </div>
      <div class="card stat">
        <div class="label">Jobs completed</div>
        <div class="value">130</div>
        <div class="delta up"><span class="arrow">▲</span>8.3% vs last week</div>
      </div>
      <div class="card stat">
        <div class="label">Open bookings</div>
        <div class="value">24</div>
        <div class="delta down"><span class="arrow">▼</span>6.0% vs last week</div>
      </div>
    </section>

    <section class="card">
      <div class="chart-head">
        <h2>Completed per day</h2>
        <span class="note">Last 7 days</span>
      </div>
      <!-- bars scale off max = 31 -->
      <div class="chart">
        <div class="col"><span class="n">14</span><div class="bar" style="height:45.2%"></div></div>
        <div class="col"><span class="n">22</span><div class="bar" style="height:71.0%"></div></div>
        <div class="col"><span class="n">18</span><div class="bar" style="height:58.1%"></div></div>
        <div class="col"><span class="n">27</span><div class="bar" style="height:87.1%"></div></div>
        <div class="col"><span class="n">31</span><div class="bar" style="height:100%"></div></div>
        <div class="col"><span class="n">12</span><div class="bar" style="height:38.7%"></div></div>
        <div class="col"><span class="n">6</span><div class="bar" style="height:19.4%"></div></div>
      </div>
      <div class="xaxis">
        <div>Mon</div><div>Tue</div><div>Wed</div><div>Thu</div>
        <div>Fri</div><div>Sat</div><div>Sun</div>
      </div>
    </section>

    <section class="card">
      <div class="table-head"><h2>Recent jobs</h2></div>
      <div class="table-wrap">
        <table>
          <thead>
            <tr><th>Job</th><th>Bike</th><th>Work</th><th>Due</th><th>Status</th></tr>
          </thead>
          <tbody>
            <tr><td class="job-id">#2041</td><td>Trail hardtail</td><td>Gear tune and chain</td><td>12 Mar</td><td><span class="pill ready">Ready for pickup</span></td></tr>
            <tr><td class="job-id">#2040</td><td>Commuter 700c</td><td>Brake pads, both wheels</td><td>12 Mar</td><td><span class="pill progress">In progress</span></td></tr>
            <tr><td class="job-id">#2039</td><td>Folding bike</td><td>New rear tyre</td><td>13 Mar</td><td><span class="pill parts">Waiting for parts</span></td></tr>
            <tr><td class="job-id">#2038</td><td>Road racer</td><td>Full service</td><td>14 Mar</td><td><span class="pill progress">In progress</span></td></tr>
            <tr><td class="job-id">#2037</td><td>Cargo trike</td><td>Wheel true, front</td><td>14 Mar</td><td><span class="pill ready">Ready for pickup</span></td></tr>
          </tbody>
        </table>
      </div>
    </section>

  </main>
</div>

<script>
  var btn = document.getElementById('menuBtn');
  var scrim = document.getElementById('scrim');
  function setNav(open){
    document.body.classList.toggle('nav-open', open);
    btn.setAttribute('aria-expanded', open ? 'true' : 'false');
  }
  btn.addEventListener('click', function(){
    setNav(!document.body.classList.contains('nav-open'));
  });
  scrim.addEventListener('click', function(){ setNav(false); });
  document.addEventListener('keydown', function(e){
    if(e.key === 'Escape') setNav(false);
  });
  document.querySelectorAll('.sidebar a').forEach(function(a){
    a.addEventListener('click', function(){ setNav(false); });
  });
</script>
</body>
</html>

A few notes on how it's put together:

  • Bar heights are percentages of the tallest value (31 = 100%), so they're genuinely to scale. If you swap in real numbers, just divide each by the max.
  • Sidebar is position: fixed on desktop with a margin-left on the content; under 760px it slides off-canvas and the hamburger toggles a nav-open class on <body>, with a dim scrim, Escape key and click-outside to close.
  • No sideways scroll: overflow-x: hidden on the body, the off-canvas panel is translated rather than laid out beside the content, and the only horizontally scrollable element is the jobs table itself (wrapped in .table-wrap), which keeps the five columns readable on a narrow screen without pushing the page wide.
  • Fonts are the system stack, the search icon is inline SVG, and the status pills use tinted backgrounds matching the mockup's green/amber/purple.
  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top2/3, 67% passed
6 of 7 checks passedfloor 2/2, middle 2/2, top 2/3
  • Passed.
    Page loads with no console errors and nothing fetched from outside
    Floor Page test
  • Passed.
    Every word and number in the picture is on the page
    Floor Page test
  • Passed.
    Wide screen: full-height left sidebar, three cards in a row, table cells in the right columns
    Middle Page test
  • Passed.
    Sidebar, active item, accent blue and the three status pills match the picture
    Middle Page test
  • Missed.
    Chart bars run Mon to Sun with heights in proportion to their valuestest group failed
    Top Page test
  • Passed.
    On a phone: no sideways scroll, cards stacked, sidebar hidden until a menu button opens it
    Top Page test
  • Passed.
    Side by side with the picture, a designer would accept it as a faithful build
    Top Read
  • Took 56 s.
  • First words after 2.6 s.
  • Wrote 5,899 tokens, 16 of them thinking.
  • 16¢ at API prices.
All numbers.
Time to finish56 s
Time to first words2.6 s
Input tokens2
Cached tokens2,320
Output tokens5,899
Thinking tokens16
Words in the answer886
Price at API rates16¢

AI answer from Opus 5 · max effort · 1 Oct 2026

7 of 7

Screenshot of the page built in this answer, at a desktop width of 1280 pixels
Our screenshot, 1280 by 800. Open it for the full length.
Screenshot of the page built in this answer, at a phone width of 375 pixels
Phone, 375 by 812.
More screenshots (1).
Screenshot of the page built in this answer, at a phone width of 375 pixels
Show the code and the answer.

Here's the whole thing as a single self-contained file — no libraries, no external fonts or images, chart drawn with scaled CSS bars.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tarnwick Bikeworks — Overview</title>
<style>
  :root{
    --navy:#1b2a44;
    --blue:#3b82f6;
    --page:#f2f4f8;
    --line:#e5e7eb;
    --line-soft:#f1f5f9;
    --ink:#111827;
    --muted:#6b7280;
    --green:#16a34a;
    --red:#dc2626;
    --sidebar-w:240px;
    --unit:4.2px;              /* chart: pixels per job */
  }

  *{box-sizing:border-box}
  html{-webkit-text-size-adjust:100%}
  html,body{margin:0;padding:0;overflow-x:hidden}
  body{
    font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif;
    background:var(--page);
    color:var(--ink);
    font-size:14px;
    line-height:1.45;
    -webkit-font-smoothing:antialiased;
  }
  h1,h2,h3{margin:0}

  /* ---------- Sidebar ---------- */
  .sidebar{
    position:fixed; inset:0 auto 0 0;
    width:var(--sidebar-w);
    background:var(--navy);
    color:#fff;
    display:flex; flex-direction:column;
    z-index:60;
  }
  .brand{
    font-size:16px; font-weight:700; letter-spacing:.2px;
    padding:0 20px; height:64px; display:flex; align-items:center;
    border-bottom:1px solid rgba(255,255,255,.08);
  }
  .nav{padding:16px 12px; display:flex; flex-direction:column; gap:4px}
  .nav a{
    display:flex; align-items:center; gap:12px;
    padding:10px 12px; border-radius:8px;
    color:#e6ebf3; text-decoration:none; font-size:14px; font-weight:500;
  }
  .nav a:hover{background:rgba(255,255,255,.07)}
  .nav a.active{background:var(--blue); color:#fff; font-weight:600}
  .nav a.active:hover{background:var(--blue)}
  .ico{width:16px; height:16px; flex:none; opacity:.95}

  /* ---------- Shell ---------- */
  .main{margin-left:var(--sidebar-w); min-width:0; min-height:100vh; display:flex; flex-direction:column}

  .topbar{
    position:sticky; top:0; z-index:40;
    background:#fff; border-bottom:1px solid var(--line);
    min-height:64px; padding:0 32px;
    display:flex; align-items:center; gap:16px; flex-wrap:wrap;
  }
  .topbar h1{font-size:20px; font-weight:700; letter-spacing:-.2px}

  .menu-btn{
    display:none; align-items:center; justify-content:center;
    width:36px; height:36px; flex:none;
    background:#fff; border:1px solid var(--line); border-radius:8px;
    color:#334155; cursor:pointer;
  }
  .menu-btn:hover{background:#f8fafc}

  .search{position:relative; margin-left:auto; width:290px; max-width:100%}
  .search svg{position:absolute; left:11px; top:50%; transform:translateY(-50%); color:#9ca3af}
  .search input{
    width:100%; height:36px;
    padding:0 12px 0 34px;
    font:inherit; font-size:14px; color:var(--ink);
    background:#fff; border:1px solid var(--line); border-radius:8px;
  }
  .search input::placeholder{color:#9ca3af}
  .search input:focus{outline:2px solid #bfdbfe; outline-offset:1px; border-color:#93c5fd}

  .avatar{
    width:32px; height:32px; flex:none; margin-left:24px;
    border-radius:50%; background:var(--navy); color:#fff;
    display:flex; align-items:center; justify-content:center;
    font-size:12px; font-weight:700; letter-spacing:.3px;
  }

  .content{padding:32px; display:flex; flex-direction:column; gap:32px}

  .card{
    background:#fff; border:1px solid var(--line); border-radius:10px;
    box-shadow:0 1px 2px rgba(16,24,40,.04);
  }

  /* ---------- Stats ---------- */
  .stats{display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:32px}
  .stat{padding:20px 24px 18px}
  .stat .label{color:var(--muted); font-size:13px; font-weight:500}
  .stat .value{font-size:32px; font-weight:700; letter-spacing:-.5px; margin:6px 0 8px}
  .stat .delta{font-size:13px; font-weight:600}
  .delta.up{color:var(--green)}
  .delta.down{color:var(--red)}
  .delta .arrow{font-size:11px; margin-right:3px}
  .delta .vs{color:inherit}

  /* ---------- Chart ---------- */
  .chart-card{padding:20px 24px 18px}
  .card-head{display:flex; align-items:baseline; justify-content:space-between; gap:16px; margin-bottom:22px}
  .card-head h2{font-size:15px; font-weight:600}
  .card-head .hint{color:var(--muted); font-size:13px}

  .plot{
    display:flex; align-items:flex-end; gap:8px;
    height:160px; border-bottom:1px solid var(--line);
  }
  .col{flex:1 1 0; min-width:0; display:flex; flex-direction:column; align-items:center; justify-content:flex-end}
  .col .val{font-size:13px; font-weight:700; margin-bottom:8px}
  .bar{
    width:100%; max-width:60px;
    height:calc(var(--v) * var(--unit));
    background:var(--blue); border-radius:3px 3px 0 0;
  }
  .days{display:flex; gap:8px; margin-top:10px}
  .days span{flex:1 1 0; min-width:0; text-align:center; font-size:13px; color:var(--muted)}

  /* ---------- Table ---------- */
  .jobs-card{padding-bottom:8px}
  .jobs-card > h2{font-size:15px; font-weight:600; padding:20px 24px}
  .table-wrap{overflow-x:auto}
  table{width:100%; border-collapse:collapse; min-width:620px}
  thead th{
    background:#f8fafc; color:var(--muted);
    font-size:12.5px; font-weight:500; text-align:left;
    padding:10px 16px;
    border-top:1px solid var(--line); border-bottom:1px solid var(--line);
    white-space:nowrap;
  }
  tbody td{padding:13px 16px; border-bottom:1px solid var(--line-soft); vertical-align:middle}
  tbody tr:last-child td{border-bottom:0}
  th:first-child,td:first-child{padding-left:24px}
  th:last-child,td:last-child{padding-right:24px}
  .jobid{font-weight:700}
  td.due{white-space:nowrap}

  .pill{
    display:inline-block; padding:4px 11px; border-radius:999px;
    font-size:12.5px; font-weight:600; white-space:nowrap;
  }
  .pill.green{background:#dcfce7; color:#15803d}
  .pill.amber{background:#fef3c7; color:#92400e}
  .pill.purple{background:#ede9fe; color:#6d28d9}

  /* ---------- Scrim ---------- */
  .scrim{
    position:fixed; inset:0; background:rgba(15,23,42,.45);
    opacity:0; pointer-events:none; transition:opacity .2s ease;
    z-index:50; display:none;
  }

  /* ---------- Mobile ---------- */
  @media (max-width:900px){
    .sidebar{
      transform:translateX(-100%);
      transition:transform .25s ease;
      box-shadow:0 10px 30px rgba(0,0,0,.25);
      width:260px;
    }
    body.nav-open .sidebar{transform:none}
    body.nav-open{overflow:hidden}
    .scrim{display:block}
    body.nav-open .scrim{opacity:1; pointer-events:auto}
    .main{margin-left:0}
    .menu-btn{display:inline-flex}

    .topbar{padding:12px 16px; gap:12px}
    .topbar h1{font-size:18px}
    .avatar{margin-left:auto}
    .search{order:3; flex:1 0 100%; width:100%; margin-left:0}

    .content{padding:16px; gap:16px}
    .stats{grid-template-columns:1fr; gap:16px}
    .stat{padding:16px}
    .chart-card{padding:16px}
    .jobs-card > h2{padding:16px}
  }

  @media (max-width:720px){
    :root{--unit:3.1px}
    .plot{height:120px; gap:4px}
    .days{gap:4px}
    .days span{font-size:12px}
    .col .val{font-size:12px; margin-bottom:6px}

    /* table collapses into stacked rows so nothing scrolls sideways */
    .table-wrap{overflow:visible}
    table{min-width:0}
    thead{position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap}
    table,tbody,tr,td{display:block; width:100%}
    tbody tr{padding:14px 16px; border-bottom:1px solid var(--line-soft)}
    tbody tr:last-child{border-bottom:0}
    tbody td{
      padding:3px 0; border:0;
      display:flex; align-items:center; justify-content:space-between; gap:16px;
      text-align:right;
    }
    tbody td::before{content:attr(data-label); color:var(--muted); font-size:12.5px; text-align:left}
    th:first-child,td:first-child,th:last-child,td:last-child{padding-left:0; padding-right:0}
  }
</style>
</head>
<body>

<aside class="sidebar" id="sidebar" aria-label="Main navigation">
  <div class="brand">Tarnwick Bikeworks</div>
  <nav class="nav">
    <a class="active" href="#" aria-current="page">
      <svg class="ico" viewBox="0 0 16 16" aria-hidden="true"><rect x="1.8" y="1.8" width="12.4" height="12.4" rx="3" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>
      Overview
    </a>
    <a href="#">
      <svg class="ico" viewBox="0 0 16 16" aria-hidden="true"><rect x="1.8" y="1.8" width="12.4" height="12.4" rx="3" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>
      Jobs
    </a>
    <a href="#">
      <svg class="ico" viewBox="0 0 16 16" aria-hidden="true"><rect x="1.8" y="1.8" width="12.4" height="12.4" rx="3" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>
      Bookings
    </a>
    <a href="#">
      <svg class="ico" viewBox="0 0 16 16" aria-hidden="true"><rect x="1.8" y="1.8" width="12.4" height="12.4" rx="3" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>
      Stock
    </a>
    <a href="#">
      <svg class="ico" viewBox="0 0 16 16" aria-hidden="true"><rect x="1.8" y="1.8" width="12.4" height="12.4" rx="3" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>
      Reports
    </a>
  </nav>
</aside>

<div class="scrim" id="scrim" hidden-aria></div>

<div class="main">
  <header class="topbar">
    <button class="menu-btn" id="menuBtn" aria-label="Open menu" aria-expanded="false" aria-controls="sidebar">
      <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
        <path d="M2 4.5h14M2 9h14M2 13.5h14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
      </svg>
    </button>

    <h1>Overview</h1>

    <div class="search">
      <svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true">
        <circle cx="7" cy="7" r="4.6" fill="none" stroke="currentColor" stroke-width="1.6"/>
        <path d="M10.6 10.6 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
      </svg>
      <input type="search" placeholder="Search jobs and bikes" aria-label="Search jobs and bikes">
    </div>

    <div class="avatar" title="Tarnwick Bikeworks">TB</div>
  </header>

  <main class="content">

    <section class="stats" aria-label="Key figures">
      <div class="card stat">
        <div class="label">Revenue</div>
        <div class="value">$8,460</div>
        <div class="delta up"><span class="arrow">▲</span>12.4% <span class="vs">vs last week</span></div>
      </div>
      <div class="card stat">
        <div class="label">Jobs completed</div>
        <div class="value">130</div>
        <div class="delta up"><span class="arrow">▲</span>8.3% <span class="vs">vs last week</span></div>
      </div>
      <div class="card stat">
        <div class="label">Open bookings</div>
        <div class="value">24</div>
        <div class="delta down"><span class="arrow">▼</span>6.0% <span class="vs">vs last week</span></div>
      </div>
    </section>

    <section class="card chart-card">
      <div class="card-head">
        <h2>Completed per day</h2>
        <span class="hint">Last 7 days</span>
      </div>

      <div class="plot" role="img" aria-label="Jobs completed per day: Monday 14, Tuesday 22, Wednesday 18, Thursday 27, Friday 31, Saturday 12, Sunday 6.">
        <div class="col"><span class="val">14</span><span class="bar" style="--v:14"></span></div>
        <div class="col"><span class="val">22</span><span class="bar" style="--v:22"></span></div>
        <div class="col"><span class="val">18</span><span class="bar" style="--v:18"></span></div>
        <div class="col"><span class="val">27</span><span class="bar" style="--v:27"></span></div>
        <div class="col"><span class="val">31</span><span class="bar" style="--v:31"></span></div>
        <div class="col"><span class="val">12</span><span class="bar" style="--v:12"></span></div>
        <div class="col"><span class="val">6</span><span class="bar" style="--v:6"></span></div>
      </div>
      <div class="days" aria-hidden="true">
        <span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span><span>Sun</span>
      </div>
    </section>

    <section class="card jobs-card">
      <h2>Recent jobs</h2>
      <div class="table-wrap">
        <table>
          <thead>
            <tr>
              <th scope="col">Job</th>
              <th scope="col">Bike</th>
              <th scope="col">Work</th>
              <th scope="col">Due</th>
              <th scope="col">Status</th>
            </tr>
          </thead>
          <tbody>
            <tr>
              <td class="jobid" data-label="Job">#2041</td>
              <td data-label="Bike">Trail hardtail</td>
              <td data-label="Work">Gear tune and chain</td>
              <td class="due" data-label="Due">12 Mar</td>
              <td data-label="Status"><span class="pill green">Ready for pickup</span></td>
            </tr>
            <tr>
              <td class="jobid" data-label="Job">#2040</td>
              <td data-label="Bike">Commuter 700c</td>
              <td data-label="Work">Brake pads, both wheels</td>
              <td class="due" data-label="Due">12 Mar</td>
              <td data-label="Status"><span class="pill amber">In progress</span></td>
            </tr>
            <tr>
              <td class="jobid" data-label="Job">#2039</td>
              <td data-label="Bike">Folding bike</td>
              <td data-label="Work">New rear tyre</td>
              <td class="due" data-label="Due">13 Mar</td>
              <td data-label="Status"><span class="pill purple">Waiting for parts</span></td>
            </tr>
            <tr>
              <td class="jobid" data-label="Job">#2038</td>
              <td data-label="Bike">Road racer</td>
              <td data-label="Work">Full service</td>
              <td class="due" data-label="Due">14 Mar</td>
              <td data-label="Status"><span class="pill amber">In progress</span></td>
            </tr>
            <tr>
              <td class="jobid" data-label="Job">#2037</td>
              <td data-label="Bike">Cargo trike</td>
              <td data-label="Work">Wheel true, front</td>
              <td class="due" data-label="Due">14 Mar</td>
              <td data-label="Status"><span class="pill green">Ready for pickup</span></td>
            </tr>
          </tbody>
        </table>
      </div>
    </section>

  </main>
</div>

<script>
  (function () {
    var body = document.body,
        btn = document.getElementById('menuBtn'),
        scrim = document.getElementById('scrim');

    function setNav(open) {
      body.classList.toggle('nav-open', open);
      btn.setAttribute('aria-expanded', String(open));
      btn.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
    }

    btn.addEventListener('click', function () {
      setNav(!body.classList.contains('nav-open'));
    });
    scrim.addEventListener('click', function () { setNav(false); });
    document.addEventListener('keydown', function (e) {
      if (e.key === 'Escape') setNav(false);
    });
    document.querySelectorAll('.nav a').forEach(function (a) {
      a.addEventListener('click', function () { setNav(false); });
    });

    var wide = window.matchMedia('(min-width: 901px)');
    var onChange = function (e) { if (e.matches) setNav(false); };
    if (wide.addEventListener) wide.addEventListener('change', onChange);
    else wide.addListener(onChange);
  })();
</script>

</body>
</html>

A few notes on how it works:

  • Bars to scale. Each bar carries style="--v:14" etc. and its height is calc(var(--v) * var(--unit)), where --unit is 4.2px on desktop (so 31 → ~130px, exactly like the mockup) and drops to 3.1px under 720px so the chart stays short on a phone. Change a number in one place and the bar redraws correctly.
  • Sidebar. Fixed 240px column on desktop; under 900px it slides off-canvas and the hamburger button appears. It opens with a dimmed backdrop, closes on backdrop click, Esc, tapping a nav link, or resizing back to desktop.
  • No sideways scroll. overflow-x:hidden on the root, min-width:0 on the flex content column, and under 720px the table drops its min-width and reflows into stacked label/value rows instead of a wide grid. Between 720–900px the table scrolls inside its own card rather than moving the page.
  • Fonts are the system UI stack, the icons/arrows are inline SVG and the ▲ ▼ glyphs, so there's nothing to download.

One thing I couldn't tell from the image: whether the stat deltas and nav items should be real links to other screens. Right now they're inert (href="#"), so wire them up when you have routes.

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top3/3, 100% passed
7 of 7 checks passedfloor 2/2, middle 2/2, top 3/3
  • Passed.
    Page loads with no console errors and nothing fetched from outside
    Floor Page test
  • Passed.
    Every word and number in the picture is on the page
    Floor Page test
  • Passed.
    Wide screen: full-height left sidebar, three cards in a row, table cells in the right columns
    Middle Page test
  • Passed.
    Sidebar, active item, accent blue and the three status pills match the picture
    Middle Page test
  • Passed.
    Chart bars run Mon to Sun with heights in proportion to their values
    Top Page test
  • Passed.
    On a phone: no sideways scroll, cards stacked, sidebar hidden until a menu button opens it
    Top Page test
  • Passed.
    Side by side with the picture, a designer would accept it as a faithful build
    Top Read
  • Took 2 min 43 s.
  • First words after 1 min 41 s.
  • Wrote 14,720 tokens, 7,505 of them thinking.
  • 38¢ at API prices.
All numbers.
Time to finish2 min 43 s
Time to first words1 min 41 s
Input tokens2
Cached tokens2,320
Output tokens14,720
Thinking tokens7,505
Words in the answer1,284
Price at API rates38¢
B03A kanban board that survives a reloadLow 9/9Max 0/9Max −9

What it asks for. Asks for a personal kanban board in one HTML file with three columns: to do, doing and done. Cards are added by typing a title, can be dragged between and within columns, edited and deleted, and each column shows how many cards it holds. The board must survive a reload, work from the keyboard with the arrow keys, show titles that contain code exactly as typed, and fit a phone screen.

Max effort passed 9 fewer checks than low.

AI answer from Opus 5 · low effort · 1 Oct 2026

9 of 9

Screenshot of the page built in this answer, at a desktop width of 1280 pixels
Our screenshot, 1280 by 800. Open it for the full length.
Screenshot of the page built in this answer, at a phone width of 375 pixels
Phone, 375 by 812.
Show the code and the answer.

Here's the complete file — save it as something like kanban.html and open it in your browser.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kanban</title>
<style>
  :root {
    --bg: #f4f5f7;
    --col: #ebecf0;
    --card: #fff;
    --line: #d8dae0;
    --text: #172b4d;
    --muted: #5e6c84;
    --accent: #0b66c3;
  }
  * { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; }
  body {
    background: var(--bg);
    color: var(--text);
    font: 16px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    padding: 16px;
    overflow-x: hidden;
  }
  h1 { font-size: 1.25rem; margin: 0 0 12px; }
  .add-row {
    display: flex;
    gap: 8px;
    margin-bottom: 16px;
    flex-wrap: wrap;
  }
  .add-row input {
    flex: 1 1 180px;
    min-width: 0;
    padding: 10px;
    font-size: 1rem;
    border: 1px solid var(--line);
    border-radius: 6px;
    background: #fff;
    color: inherit;
  }
  button {
    font: inherit;
    cursor: pointer;
    border: 1px solid var(--line);
    background: #fff;
    color: inherit;
    border-radius: 6px;
    padding: 8px 12px;
  }
  button:hover { background: #f0f1f4; }
  .add-row button {
    background: var(--accent);
    border-color: var(--accent);
    color: #fff;
    font-weight: 600;
    padding: 10px 16px;
  }
  .add-row button:hover { background: #09529c; }

  .board {
    display: flex;
    gap: 12px;
    align-items: flex-start;
  }
  .column {
    flex: 1 1 0;
    min-width: 0;
    background: var(--col);
    border-radius: 8px;
    padding: 10px;
  }
  .column.drop-target { outline: 2px dashed var(--accent); outline-offset: -2px; }
  .column h2 {
    font-size: 0.85rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--muted);
    margin: 0 0 10px;
    overflow-wrap: anywhere;
  }
  .column h2 .count { color: var(--text); }
  .cards { list-style: none; margin: 0; padding: 0; min-height: 36px; }
  .empty {
    color: var(--muted);
    font-size: 0.85rem;
    font-style: italic;
    padding: 6px 2px;
  }

  .card {
    background: var(--card);
    border: 1px solid var(--line);
    border-radius: 6px;
    padding: 8px;
    margin-bottom: 8px;
    box-shadow: 0 1px 1px rgba(9,30,66,.12);
    cursor: grab;
  }
  .card:focus-visible, .card:focus {
    outline: 3px solid var(--accent);
    outline-offset: 1px;
  }
  .card.dragging { opacity: .4; }
  .card .title {
    display: block;
    overflow-wrap: anywhere;
    word-break: break-word;
    white-space: pre-wrap;
    margin-bottom: 8px;
  }
  .card .actions { display: flex; gap: 6px; flex-wrap: wrap; }
  .card .actions button { padding: 4px 8px; font-size: 0.8rem; }
  .card input.edit {
    width: 100%;
    min-width: 0;
    padding: 6px;
    font: inherit;
    border: 1px solid var(--accent);
    border-radius: 4px;
    margin-bottom: 8px;
  }
  .hint { color: var(--muted); font-size: 0.8rem; margin: 14px 0 0; }

  @media (max-width: 620px) {
    body { padding: 10px; }
    .board { flex-direction: column; }
    .column { width: 100%; flex: 1 1 auto; }
  }
</style>
</head>
<body>

<h1>Kanban</h1>

<div class="add-row">
  <input id="new-title" type="text" placeholder="New card title" aria-label="New card title" autocomplete="off">
  <button id="add-btn" type="button">Add card</button>
</div>

<div class="board" id="board"></div>

<p class="hint">Tab to a card, then use the arrow keys: left/right to change column, up/down to reorder.</p>

<script>
(function () {
  "use strict";

  var COLUMNS = [
    { key: "todo",  name: "To do" },
    { key: "doing", name: "Doing" },
    { key: "done",  name: "Done" }
  ];
  var STORAGE_KEY = "kanban.board.v1";

  var state = load();
  var nextId = computeNextId();
  var focusId = null;      // card to focus after the next render
  var editingId = null;    // card currently in edit mode

  var boardEl = document.getElementById("board");
  var inputEl = document.getElementById("new-title");

  /* ---------- storage ---------- */

  function emptyState() {
    return { todo: [], doing: [], done: [] };
  }

  function load() {
    try {
      var raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) return emptyState();
      var parsed = JSON.parse(raw);
      var out = emptyState();
      COLUMNS.forEach(function (c) {
        var list = parsed && parsed[c.key];
        if (Array.isArray(list)) {
          list.forEach(function (card) {
            if (card && typeof card.title === "string") {
              out[c.key].push({ id: Number(card.id) || 0, title: card.title });
            }
          });
        }
      });
      return out;
    } catch (e) {
      return emptyState();
    }
  }

  function save() {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
    } catch (e) { /* storage full or blocked: keep working in memory */ }
  }

  function computeNextId() {
    var max = 0;
    COLUMNS.forEach(function (c) {
      state[c.key].forEach(function (card) {
        if (card.id > max) max = card.id;
      });
    });
    return max + 1;
  }

  /* ---------- helpers ---------- */

  function findCard(id) {
    for (var i = 0; i < COLUMNS.length; i++) {
      var key = COLUMNS[i].key;
      var list = state[key];
      for (var j = 0; j < list.length; j++) {
        if (list[j].id === id) return { colKey: key, colIndex: i, index: j, card: list[j] };
      }
    }
    return null;
  }

  /* ---------- actions ---------- */

  function addCard(rawTitle) {
    var title = String(rawTitle).trim();
    if (!title) return false;
    state.todo.push({ id: nextId++, title: title });
    save();
    render();
    return true;
  }

  function deleteCard(id) {
    var found = findCard(id);
    if (!found) return;
    state[found.colKey].splice(found.index, 1);
    if (editingId === id) editingId = null;
    save();
    render();
  }

  function saveTitle(id, rawTitle) {
    var found = findCard(id);
    if (!found) return;
    var title = String(rawTitle).trim();
    if (title) found.card.title = title;  // empty edit = keep the old title
    editingId = null;
    focusId = id;
    save();
    render();
  }

  // Move a card to a column at a given index. Returns true if anything changed.
  function moveCard(id, toColKey, toIndex) {
    var found = findCard(id);
    if (!found) return false;
    var from = state[found.colKey];
    var to = state[toColKey];

    if (found.colKey === toColKey) {
      if (toIndex > found.index) toIndex--;      // account for its own removal
      if (toIndex < 0) toIndex = 0;
      if (toIndex > to.length - 1) toIndex = to.length - 1;
      if (toIndex === found.index) return false;
      from.splice(found.index, 1);
      to.splice(toIndex, 0, found.card);
    } else {
      if (toIndex < 0) toIndex = 0;
      if (toIndex > to.length) toIndex = to.length;
      from.splice(found.index, 1);
      to.splice(toIndex, 0, found.card);
    }
    save();
    return true;
  }

  function nudge(id, dir) {
    var found = findCard(id);
    if (!found) return;
    var changed = false;

    if (dir === "up") {
      changed = moveCard(id, found.colKey, found.index - 1);
    } else if (dir === "down") {
      changed = moveCard(id, found.colKey, found.index + 2);
    } else {
      var target = found.colIndex + (dir === "left" ? -1 : 1);
      if (target < 0 || target >= COLUMNS.length) return;
      var targetKey = COLUMNS[target].key;
      var at = Math.min(found.index, state[targetKey].length);
      changed = moveCard(id, targetKey, at);
    }

    focusId = id;              // keep focus on the card so arrows can continue
    if (changed) render();
    else focusCard(id);
  }

  function focusCard(id) {
    var el = boardEl.querySelector('.card[data-id="' + id + '"]');
    if (el) el.focus();
  }

  /* ---------- rendering (all text via textContent, never innerHTML) ---------- */

  function buildCard(card, colKey) {
    var li = document.createElement("li");
    li.className = "card";
    li.dataset.id = String(card.id);
    li.draggable = true;
    li.tabIndex = 0;
    li.setAttribute("role", "listitem");

    if (editingId === card.id) {
      var input = document.createElement("input");
      input.type = "text";
      input.className = "edit";
      input.value = card.title;            // value is text, not markup
      input.setAttribute("aria-label", "Edit card title");
      li.draggable = false;
      li.appendChild(input);

      var actions = document.createElement("div");
      actions.className = "actions";

      var saveBtn = document.createElement("button");
      saveBtn.type = "button";
      saveBtn.textContent = "Save";
      saveBtn.addEventListener("click", function () { saveTitle(card.id, input.value); });

      var cancelBtn = document.createElement("button");
      cancelBtn.type = "button";
      cancelBtn.textContent = "Cancel";
      cancelBtn.addEventListener("click", function () {
        editingId = null;
        focusId = card.id;
        render();
      });

      actions.appendChild(saveBtn);
      actions.appendChild(cancelBtn);
      li.appendChild(actions);

      input.addEventListener("keydown", function (e) {
        if (e.key === "Enter") {
          e.preventDefault();
          saveTitle(card.id, input.value);
        } else if (e.key === "Escape") {
          e.preventDefault();
          editingId = null;
          focusId = card.id;
          render();
        }
      });

      return li;
    }

    var title = document.createElement("span");
    title.className = "title";
    title.textContent = card.title;        // <div> and a & b appear literally
    li.appendChild(title);

    var acts = document.createElement("div");
    acts.className = "actions";

    var editBtn = document.createElement("button");
    editBtn.type = "button";
    editBtn.textContent = "Edit";
    editBtn.addEventListener("click", function () {
      editingId = card.id;
      render();
      var inp = boardEl.querySelector('.card[data-id="' + card.id + '"] input.edit');
      if (inp) { inp.focus(); inp.select(); }
    });

    var delBtn = document.createElement("button");
    delBtn.type = "button";
    delBtn.textContent = "Delete";
    delBtn.addEventListener("click", function () { deleteCard(card.id); });

    acts.appendChild(editBtn);
    acts.appendChild(delBtn);
    li.appendChild(acts);

    li.addEventListener("keydown", function (e) {
      if (e.target !== li) return;         // don't hijack typing in the edit field
      var map = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down" };
      var dir = map[e.key];
      if (!dir) return;
      e.preventDefault();
      nudge(card.id, dir);
    });

    li.addEventListener("dragstart", function (e) {
      if (e.dataTransfer) {
        e.dataTransfer.setData("text/plain", String(card.id));
        e.dataTransfer.effectAllowed = "move";
      }
      dragId = card.id;
      li.classList.add("dragging");
    });
    li.addEventListener("dragend", function () {
      dragId = null;
      li.classList.remove("dragging");
      clearTargets();
    });

    return li;
  }

  function render() {
    boardEl.textContent = "";

    COLUMNS.forEach(function (col) {
      var section = document.createElement("section");
      section.className = "column";
      section.dataset.col = col.key;

      var h2 = document.createElement("h2");
      h2.appendChild(document.createTextNode(col.name + " "));
      var count = document.createElement("span");
      count.className = "count";
      count.textContent = String(state[col.key].length);
      h2.appendChild(count);
      section.appendChild(h2);

      var ul = document.createElement("ul");
      ul.className = "cards";
      ul.setAttribute("role", "list");
      ul.setAttribute("aria-label", col.name);

      state[col.key].forEach(function (card) {
        ul.appendChild(buildCard(card, col.key));
      });

      if (state[col.key].length === 0) {
        var empty = document.createElement("div");
        empty.className = "empty";
        empty.textContent = "No cards";
        ul.appendChild(empty);
      }

      section.appendChild(ul);
      wireDrop(section, ul, col.key);
      boardEl.appendChild(section);
    });

    if (focusId !== null) {
      focusCard(focusId);
      focusId = null;
    }
  }

  /* ---------- drag and drop ---------- */

  var dragId = null;

  function clearTargets() {
    var list = boardEl.querySelectorAll(".column.drop-target");
    for (var i = 0; i < list.length; i++) list[i].classList.remove("drop-target");
  }

  function dropIndex(ul, y) {
    var cards = ul.querySelectorAll(".card");
    for (var i = 0; i < cards.length; i++) {
      var r = cards[i].getBoundingClientRect();
      if (y < r.top + r.height / 2) return i;
    }
    return cards.length;
  }

  function wireDrop(section, ul, colKey) {
    section.addEventListener("dragover", function (e) {
      e.preventDefault();
      if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
      section.classList.add("drop-target");
    });
    section.addEventListener("dragleave", function (e) {
      if (!section.contains(e.relatedTarget)) section.classList.remove("drop-target");
    });
    section.addEventListener("drop", function (e) {
      e.preventDefault();
      section.classList.remove("drop-target");
      var id = dragId;
      if (id === null && e.dataTransfer) id = Number(e.dataTransfer.getData("text/plain"));
      if (!id && id !== 0) return;
      var idx = dropIndex(ul, e.clientY);
      var found = findCard(id);
      if (found && found.colKey === colKey) idx = idx + (idx > found.index ? 1 : 0);
      if (moveCard(id, colKey, idx)) {
        focusId = id;
        render();
      }
      dragId = null;
    });
  }

  /* ---------- adding ---------- */

  document.getElementById("add-btn").addEventListener("click", function () {
    if (addCard(inputEl.value)) inputEl.value = "";
    inputEl.focus();
  });

  inputEl.addEventListener("keydown", function (e) {
    if (e.key !== "Enter") return;
    e.preventDefault();
    if (addCard(inputEl.value)) inputEl.value = "";
  });

  render();
})();
</script>
</body>
</html>

A few notes on how it behaves:

  • Nothing external. No fonts, scripts, images or network requests of any kind; it works offline and from a file:// URL.
  • Text safety. Every title goes in via textContent (and input.value while editing), so <div> or a & b is shown character for character and never parsed as markup.
  • Saving. The whole board lives in localStorage under one key, written after every change, so a reload restores the exact columns and order. If storage is blocked it still runs, just without persistence.
  • Keyboard. Each card is tabindex="0", so Tab lands on the card before its Edit/Delete buttons. Arrow keys move it and the card is re-focused after every move, so you can hold a direction and keep going. When crossing columns it tries to keep the same position in the list; when it's already at the edge, nothing happens. Arrow keys inside the edit field are left alone, and Escape cancels an edit.
  • Edits. Enter or Save confirms; a title that's only spaces is treated as "no change" rather than wiping the card.
  • Phone. Below 620px the three columns stack vertically, and long titles wrap with overflow-wrap: anywhere, so there's no horizontal scrolling at 375px. Note that HTML5 drag-and-drop doesn't work with touch on most mobile browsers — on a phone, use the keyboard arrows (or an external keyboard) to move cards; the drag works on desktop.
  • Floor2/2, 100% passed
  • Middle3/3, 100% passed
  • Top4/4, 100% passed
9 of 9 checks passedfloor 2/2, middle 3/3, top 4/4
  • Passed.
    The page loads with no errors and has the three named columns
    Floor Page test
  • Passed.
    Adding a card works with Enter and with the Add card button
    Floor Page test
  • Passed.
    Empty or space-only titles are ignored
    Middle Page test
  • Passed.
    The counts update, and Edit and Delete work
    Middle Page test
  • Passed.
    After a reload the board is exactly as it was
    Middle Page test
  • Passed.
    Dragging moves cards between columns and reorders them, and it survives a reload
    Top Page test
  • Passed.
    A card can be moved with the arrow keys and keeps focus
    Top Page test
  • Passed.
    Titles like <b>hi</b> & <img ...> show exactly as typed and run nothing
    Top Page test
  • Passed.
    No sideways scroll on a 375 px phone, even with very long titles
    Top Page test
  • Took 1 min 5 s.
  • First words after 8.5 s.
  • Wrote 6,719 tokens, 411 of them thinking.
  • 18¢ at API prices.
All numbers.
Time to finish1 min 5 s
Time to first words8.5 s
Input tokens2
Cached tokens1,162
Output tokens6,719
Thinking tokens411
Words in the answer1,813
Price at API rates18¢

AI answer from Opus 5 · max effort · 1 Oct 2026

0 of 9

The answer was cut off at the token limit.

Here's the whole thing — one self-contained file, no libraries, no network requests.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Kanban</title>
<style>
  :root{
    --bg:#f4f5f7; --panel:#ebecf0; --card:#fff; --ink:#172b4d; --muted:#5e6c84;
    --line:#dfe1e6; --accent:#0b66c3; --danger:#b4232b;
  }
  @media (prefers-color-scheme: dark){
    :root{
      --bg:#14161a; --panel:#1e2228; --card:#272b33; --ink:#e7eaf0; --muted:#9aa4b2;
      --line:#3b434d; --accent:#6aa9ff; --danger:#ff8f8f;
    }
  }
  *,*::before,*::after{box-sizing:border-box}
  html{-webkit-text-size-adjust:100%}
  body{
    margin:0; padding:14px;
    font:16px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
    background:var(--bg); color:var(--ink);
  }
  .wrap{max-width:1080px; margin:0 auto}
  h1{font-size:1.2rem; margin:0 0 10px}

  .add{display:flex; gap:8px; flex-wrap:wrap; margin:0 0 6px}
  .add input{
    flex:1 1 180px; min-width:0; padding:10px 12px; font-size:1rem;
    border:1px solid var(--line); border-radius:8px; background:var(--card); color:inherit;
  }
  button{
    font:inherit; cursor:pointer; padding:9px 13px; border-radius:8px;
    border:1px solid var(--line); background:var(--card); color:inherit;
  }
  button:hover{border-color:var(--accent)}
  .primary{background:var(--accent); border-color:var(--accent); color:#fff; font-weight:600}
  .primary:hover{filter:brightness(1.07)}
  .danger:hover{border-color:var(--danger); color:var(--danger)}
  :focus-visible{outline:2px solid var(--accent); outline-offset:2px}

  .hint{color:var(--muted); font-size:.82rem; margin:0 0 14px}
  .hint kbd{
    font:inherit; font-size:.78rem; border:1px solid var(--line); border-bottom-width:2px;
    border-radius:4px; padding:0 4px; background:var(--card);
  }

  .board{display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; align-items:start}
  @media (max-width:760px){ .board{grid-template-columns:1fr} }

  .col{background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:10px; min-width:0}
  .col h2{display:flex; align-items:center; gap:6px; font-size:.95rem; margin:2px 2px 10px}
  .count{
    font-variant-numeric:tabular-nums; font-size:.8rem; color:var(--muted);
    background:var(--card); border:1px solid var(--line); border-radius:999px; padding:1px 9px;
  }

  .list{list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:8px; min-height:52px}
  .list.empty{border:1px dashed var(--line); border-radius:8px; align-items:center; justify-content:center}
  .list.empty::after{content:"No cards"; color:var(--muted); font-size:.8rem}
  .list.drop-in{outline:2px dashed var(--accent); outline-offset:2px; border-radius:8px}

  .card{
    position:relative; display:flex; flex-wrap:wrap; align-items:flex-start; gap:8px;
    background:var(--card); border:1px solid var(--line); border-radius:10px; padding:8px 10px;
  }
  .handle{
    flex:0 0 auto; touch-action:none; cursor:grab; color:var(--muted);
    user-select:none; -webkit-user-select:none; padding:2px 1px; line-height:1.2;
  }
  .handle:active{cursor:grabbing}
  .title{flex:1 1 auto; min-width:0; white-space:pre-wrap; overflow-wrap:anywhere; word-break:break-word}
  .actions{display:flex; gap:6px; margin-left:auto}
  .actions button{padding:6px 10px; font-size:.82rem}
  .edit-input{
    flex:1 1 100%; min-width:0; padding:8px 10px; font-size:1rem;
    border:1px solid var(--accent); border-radius:8px; background:var(--bg); color:inherit;
  }

  .card.dragging{opacity:.35}
  .card.ghost{
    position:fixed; left:0; top:0; z-index:999; pointer-events:none; margin:0;
    transform:translate(-9999px,-9999px); box-shadow:0 10px 26px rgba(0,0,0,.3); opacity:.96;
  }
  .card.drop-before::before,.card.drop-after::after{
    content:""; position:absolute; left:0; right:0; height:3px; border-radius:3px; background:var(--accent);
  }
  .card.drop-before::before{top:-6px}
  .card.drop-after::after{bottom:-6px}
  body.is-dragging{user-select:none; -webkit-user-select:none}

  .sr-only{
    position:absolute; width:1px; height:1px; padding:0; margin:-1px;
    overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0;
  }
</style>
</head>
<body>
<div class="wrap">
  <h1>Kanban</h1>

  <form class="add" id="add-form" autocomplete="off">
    <label class="sr-only" for="new-title">New card title</label>
    <input id="new-title" type="text" placeholder="Card title, then Enter" enterkeyhint="done">
    <button type="submit" class="primary">Add card</button>
  </form>

  <p class="hint">
    Drag a card by its <span aria-hidden="true">⠿</span> grip (or anywhere on it with a mouse).
    Or <kbd>Tab</kbd> to a card and use <kbd>←</kbd> <kbd>→</kbd> to change column,
    <kbd>↑</kbd> <kbd>↓</kbd> to reorder, <kbd>Enter</kbd> to edit.
  </p>

  <main class="board" id="board"></main>
</div>

<div id="live" class="sr-only" role="status" aria-live="polite"></div>

<script>
(function(){
  "use strict";

  var KEY = "kanban.v1";
  var COLS = [
    { key:"todo",  name:"To do" },
    { key:"doing", name:"Doing" },
    { key:"done",  name:"Done"  }
  ];

  var board = document.getElementById("board");
  var live  = document.getElementById("live");
  var form  = document.getElementById("add-form");
  var input = document.getElementById("new-title");

  var state = load();
  var editingId = null;
  var focusEditInput = false;

  /* ---------- storage ---------- */

  function blank(){ return { todo:[], doing:[], done:[] }; }

  function newId(){
    return "c" + Date.now().toString(36) + Math.random().toString(36).slice(2,8);
  }

  function load(){
    try{
      var raw = localStorage.getItem(KEY);
      if(!raw) return blank();
      var data = JSON.parse(raw);
      if(!data || typeof data !== "object") return blank();
      var out = blank(), seen = {};
      COLS.forEach(function(c){
        var arr = Array.isArray(data[c.key]) ? data[c.key] : [];
        out[c.key] = arr.filter(function(it){
          return it && typeof it.title === "string";
        }).map(function(it){
          var id = typeof it.id === "string" ? it.id : "";
          if(!id || seen[id]) id = newId();
          seen[id] = true;
          return { id:id, title:it.title };
        });
      });
      return out;
    }catch(e){ return blank(); }
  }

  function save(){
    try{ localStorage.setItem(KEY, JSON.stringify(state)); }catch(e){}
  }

  /* ---------- helpers ---------- */

  function colName(key){
    for(var i=0;i<COLS.length;i++) if(COLS[i].key===key) return COLS[i].name;
    return key;
  }

  function find(id){
    for(var i=0;i<COLS.length;i++){
      var k = COLS[i].key, arr = state[k];
      for(var j=0;j<arr.length;j++) if(arr[j].id === id) return { col:k, index:j };
    }
    return null;
  }

  function cardElById(id){
    var els = board.querySelectorAll(".card");
    for(var i=0;i<els.length;i++) if(els[i].dataset.id === id) return els[i];
    return null;
  }

  function focusCard(id){
    var el = cardElById(id);
    if(!el) return;
    el.focus();
    if(el.scrollIntoView) el.scrollIntoView({ block:"nearest", inline:"nearest" });
  }

  function announce(msg){ live.textContent = msg; }

  /* ---------- build the three columns ---------- */

  var ui = {};
  COLS.forEach(function(c){
    var sec = document.createElement("section");
    sec.className = "col";
    sec.dataset.col = c.key;

    var h = document.createElement("h2");
    h.id = "head-" + c.key;
    var nm = document.createElement("span");
    nm.textContent = c.name;
    var cnt = document.createElement("span");
    cnt.className = "count";
    cnt.textContent = "0";
    h.appendChild(nm);
    h.appendChild(document.createTextNode(" "));
    h.appendChild(cnt);

    var ul = document.createElement("ul");
    ul.className = "list";
    ul.dataset.col = c.key;
    ul.setAttribute("aria-labelledby", h.id);

    sec.appendChild(h);
    sec.appendChild(ul);
    board.appendChild(sec);

    ui[c.key] = { section:sec, list:ul, count:cnt };
  });

  /* ---------- rendering (all user text via textContent / .value) ---------- */

  function mkBtn(label, act, cls){
    var b = document.createElement("button");
    b.type = "button";
    b.textContent = label;
    b.dataset.act = act;
    if(cls) b.classList.add(cls);
    return b;
  }

  function buildCard(card, col, i, total){
    var li = document.createElement("li");
    li.className = "card";
    li.dataset.id = card.id;
    li.dataset.col = col.key;

    if(card.id === editing
  • Floor0/2, 0% passed
  • Middle0/3, 0% passed
  • Top0/4, 0% passed
0 of 9 checks passedfloor 0/2, middle 0/3, top 0/4
  • Missed.
    The page loads with no errors and has the three named columnstest group failed
    Floor Page test
  • Missed.
    Adding a card works with Enter and with the Add card buttontest group failed
    Floor Page test
  • Missed.
    Empty or space-only titles are ignoredtest group failed
    Middle Page test
  • Missed.
    The counts update, and Edit and Delete worktest group failed
    Middle Page test
  • Missed.
    After a reload the board is exactly as it wastest group failed
    Middle Page test
  • Missed.
    Dragging moves cards between columns and reorders them, and it survives a reloadtest group failed
    Top Page test
  • Missed.
    A card can be moved with the arrow keys and keeps focustest group failed
    Top Page test
  • Missed.
    Titles like <b>hi</b> & <img ...> show exactly as typed and run nothingtest group failed
    Top Page test
  • Missed.
    No sideways scroll on a 375 px phone, even with very long titlestest group failed
    Top Page test
  • Took 6 min 22 s.
  • First words after 5 min 50 s.
  • Wrote 32,000 tokens, 28,148 of them thinking.
  • 81¢ at API prices.
All numbers.
Time to finish6 min 22 s
Time to first words5 min 50 s
Input tokens2
Cached tokens1,162
Output tokens32,000
Thinking tokens28,148
Words in the answer742
Price at API rates81¢
B04Minesweeper in one HTML fileLow 6/9Max 8/9Max +2

What it asks for. Asks for the game Minesweeper in one HTML file: a 9 by 9 board with 10 mines, flags on right click, a mines-left counter, a first square that is never a mine, empty areas that open by themselves, win and lose messages and a new game button. Every square has to be a button so the game works from the keyboard, and the board has to fit a phone.

Max effort passed 2 more checks than low.

AI answer from Opus 5 · low effort · 1 Oct 2026

6 of 9

Screenshot of the page built in this answer, at a desktop width of 1280 pixels
Our screenshot, 1280 by 800. Open it for the full length.
Screenshot of the page built in this answer, at a phone width of 375 pixels
Phone, 375 by 812.
More screenshots (2).
Screenshot of the page built in this answer, at a desktop width of 1280 pixelsScreenshot of the page built in this answer, at a phone width of 375 pixels
Show the code and the answer.

Here's the complete file — save it as minesweeper.html and open it in any browser.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Minesweeper</title>
<style>
  :root { --cell: 34px; }
  * { box-sizing: border-box; }
  body {
    margin: 0;
    padding: 12px 6px;
    font-family: system-ui, -apple-system, "Segoe UI", Arial, sans-serif;
    background: #eceff1;
    color: #212121;
    display: flex;
    flex-direction: column;
    align-items: center;
  }
  h1 { font-size: 1.25rem; margin: 0 0 10px; }
  .panel {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-bottom: 10px;
    flex-wrap: wrap;
    justify-content: center;
  }
  .counter {
    font-variant-numeric: tabular-nums;
    font-weight: 700;
    background: #fff;
    border: 1px solid #b0bec5;
    border-radius: 6px;
    padding: 6px 10px;
  }
  #newgame {
    font: inherit;
    font-weight: 600;
    padding: 8px 14px;
    min-height: 36px;
    border: 1px solid #90a4ae;
    border-radius: 6px;
    background: #fff;
    cursor: pointer;
  }
  #newgame:hover { background: #f5f5f5; }
  #message {
    min-height: 1.6em;
    font-weight: 700;
    font-size: 1.05rem;
    margin-bottom: 8px;
  }
  #board {
    display: grid;
    grid-template-columns: repeat(9, var(--cell));
    gap: 2px;
    background: #90a4ae;
    padding: 2px;
    border-radius: 4px;
    touch-action: manipulation;
  }
  #board.over { pointer-events: none; }
  .cell {
    width: var(--cell);
    height: var(--cell);
    min-width: 32px;
    min-height: 32px;
    padding: 0;
    margin: 0;
    font: inherit;
    font-size: 17px;
    font-weight: 700;
    line-height: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    border: 2px outset #cfd8dc;
    background: #cfd8dc;
    cursor: pointer;
    user-select: none;
    -webkit-user-select: none;
  }
  .cell:focus-visible { outline: 3px solid #1565c0; outline-offset: -3px; }
  .cell.open {
    border: 1px solid #b0bec5;
    background: #fafafa;
    cursor: default;
  }
  .cell.mine { background: #ef9a9a; }
  .n1 { color: #1565c0; } .n2 { color: #2e7d32; } .n3 { color: #c62828; }
  .n4 { color: #4527a0; } .n5 { color: #8d6e63; } .n6 { color: #00838f; }
  .n7 { color: #424242; } .n8 { color: #757575; }
  .hint { font-size: .8rem; color: #546e7a; margin-top: 10px; text-align: center; max-width: 340px; }
  @media (max-width: 375px) { :root { --cell: 33px; } }
</style>
</head>
<body>

<h1>Minesweeper</h1>

<div class="panel">
  <span class="counter">Mines left: <span id="minesleft">10</span></span>
  <button id="newgame" type="button">New game</button>
</div>

<div id="message" role="status" aria-live="polite"></div>

<div id="board" role="grid" aria-label="Minesweeper board"></div>

<p class="hint">Click or press Enter/Space to open a square. Right click (or long press) to flag.</p>

<script>
(function () {
  var ROWS = 9, COLS = 9, MINES = 10;

  var boardEl = document.getElementById('board');
  var minesLeftEl = document.getElementById('minesleft');
  var messageEl = document.getElementById('message');
  var newGameBtn = document.getElementById('newgame');

  var cells = [];      // button elements, indexed r*COLS+c
  var mine = [];       // boolean
  var opened = [];     // boolean
  var flagged = [];    // boolean
  var counts = [];     // number of adjacent mines
  var placed = false;  // mines placed yet?
  var finished = false;
  var openedCount = 0;

  function idx(r, c) { return r * COLS + c; }

  function neighbours(i) {
    var r = Math.floor(i / COLS), c = i % COLS, out = [];
    for (var dr = -1; dr <= 1; dr++) {
      for (var dc = -1; dc <= 1; dc++) {
        if (dr === 0 && dc === 0) continue;
        var nr = r + dr, nc = c + dc;
        if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS) out.push(idx(nr, nc));
      }
    }
    return out;
  }

  function buildBoard() {
    boardEl.innerHTML = '';
    cells = [];
    for (var r = 0; r < ROWS; r++) {
      for (var c = 0; c < COLS; c++) {
        var b = document.createElement('button');
        b.type = 'button';
        b.className = 'cell';
        b.dataset.i = idx(r, c);
        b.setAttribute('aria-label', 'Row ' + (r + 1) + ', column ' + (c + 1));
        boardEl.appendChild(b);
        cells.push(b);
      }
    }
  }

  function reset() {
    mine = []; opened = []; flagged = []; counts = [];
    for (var i = 0; i < ROWS * COLS; i++) {
      mine[i] = false; opened[i] = false; flagged[i] = false; counts[i] = 0;
    }
    placed = false;
    finished = false;
    openedCount = 0;
    boardEl.classList.remove('over');
    messageEl.textContent = '';
    for (var j = 0; j < cells.length; j++) {
      cells[j].className = 'cell';
      cells[j].textContent = '';
      cells[j].disabled = false;
    }
    updateCounter();
  }

  function updateCounter() {
    var flags = 0;
    for (var i = 0; i < flagged.length; i++) if (flagged[i]) flags++;
    minesLeftEl.textContent = String(MINES - flags);
  }

  // Place mines, never on the first clicked square (or its neighbours where possible).
  function placeMines(safe) {
    var banned = {};
    banned[safe] = true;
    var nb = neighbours(safe);
    var total = ROWS * COLS;
    if (total - (nb.length + 1) >= MINES) {
      for (var k = 0; k < nb.length; k++) banned[nb[k]] = true;
    }
    var pool = [];
    for (var i = 0; i < total; i++) if (!banned[i]) pool.push(i);
    for (var n = pool.length - 1; n > 0; n--) {
      var j = Math.floor(Math.random() * (n + 1));
      var t = pool[n]; pool[n] = pool[j]; pool[j] = t;
    }
    for (var m = 0; m < MINES; m++) mine[pool[m]] = true;

    for (var p = 0; p < total; p++) {
      var cnt = 0, ns = neighbours(p);
      for (var q = 0; q < ns.length; q++) if (mine[ns[q]]) cnt++;
      counts[p] = cnt;
    }
    placed = true;
  }

  function open(start) {
    if (finished || opened[start] || flagged[start]) return;

    if (!placed) placeMines(start);

    if (mine[start]) { lose(start); return; }

    var stack = [start];
    while (stack.length) {
      var i = stack.pop();
      if (opened[i] || flagged[i] || mine[i]) continue;
      opened[i] = true;
      openedCount++;
      var el = cells[i];
      el.classList.add('open');
      el.disabled = true;
      if (counts[i] > 0) {
        el.textContent = String(counts[i]);
        el.classList.add('n' + counts[i]);
      } else {
        el.textContent = '';
        var ns = neighbours(i);
        for (var k = 0; k < ns.length; k++) {
          if (!opened[ns[k]] && !flagged[ns[k]]) stack.push(ns[k]);
        }
      }
    }

    if (openedCount === ROWS * COLS - MINES) win();
  }

  function toggleFlag(i) {
    if (finished || opened[i]) return;
    flagged[i] = !flagged[i];
    cells[i].textContent = flagged[i] ? '\uD83D\uDEA9' : '';
    cells[i].setAttribute('aria-pressed', flagged[i] ? 'true' : 'false');
    updateCounter();
  }

  function lose(hit) {
    finished = true;
    for (var i = 0; i < mine.length; i++) {
      if (mine[i]) {
        cells[i].textContent = '\uD83D\uDCA3';
        cells[i].classList.add('open', 'mine');
      }
    }
    if (hit != null) cells[hit].classList.add('mine');
    boardEl.classList.add('over');
    for (var j = 0; j < cells.length; j++) cells[j].disabled = true;
    messageEl.textContent = 'Game over';
  }

  function win() {
    finished = true;
    boardEl.classList.add('over');
    for (var i = 0; i < cells.length; i++) {
      if (mine[i] && !flagged[i]) {
        cells[i].textContent = '\uD83D\uDEA9';
        flagged[i] = true;
      }
      cells[i].disabled = true;
    }
    updateCounter();
    messageEl.textContent = 'You win!';
  }

  boardEl.addEventListener('click', function (e) {
    var t = e.target.closest('.cell');
    if (!t) return;
    open(Number(t.dataset.i));
  });

  boardEl.addEventListener('contextmenu', function (e) {
    e.preventDefault();
    var t = e.target.closest('.cell');
    if (!t) return;
    toggleFlag(Number(t.dataset.i));
  });

  // Long press on touch screens = flag.
  var pressTimer = null, pressedIndex = null, longPressed = false;
  boardEl.addEventListener('touchstart', function (e) {
    var t = e.target.closest('.cell');
    if (!t) return;
    pressedIndex = Number(t.dataset.i);
    longPressed = false;
    pressTimer = setTimeout(function () {
      longPressed = true;
      toggleFlag(pressedIndex);
    }, 450);
  }, { passive: true });

  function cancelPress() {
    if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; }
  }
  boardEl.addEventListener('touchend', function (e) {
    cancelPress();
    if (longPressed) { e.preventDefault(); longPressed = false; }
  });
  boardEl.addEventListener('touchmove', cancelPress, { passive: true });
  boardEl.addEventListener('touchcancel', cancelPress, { passive: true });

  // Keyboard: arrows move, Enter/Space open, F flags.
  boardEl.addEventListener('keydown', function (e) {
    var t = e.target.closest('.cell');
    if (!t) return;
    var i = Number(t.dataset.i), r = Math.floor(i / COLS), c = i % COLS;
    var nr = r, nc = c;
    if (e.key === 'ArrowUp') nr = r - 1;
    else if (e.key === 'ArrowDown') nr = r + 1;
    else if (e.key === 'ArrowLeft') nc = c - 1;
    else if (e.key === 'ArrowRight') nc = c + 1;
    else if (e.key === 'f' || e.key === 'F') { e.preventDefault(); toggleFlag(i); return; }
    else return;
    e.preventDefault();
    if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) return;
    cells[idx(nr, nc)].focus();
  });

  newGameBtn.addEventListener('click', reset);

  buildBoard();
  reset();
})();
</script>
</body>
</html>

Notes on the bits you may want to tweak:

  • Keyboard: a focused square opens with Enter or Space (native button behaviour). Since browsers give no right-click key, F toggles a flag, and arrow keys move between squares. On touch devices a long press (about half a second) also flags.
  • First click safety: mines are placed only after your first open, avoiding that square and its neighbours, so your first move is usually a clear patch rather than a bare number.
  • Width: squares are 34px (33px under 375px), so 9 columns plus gaps and padding stay under 320px — no sideways scrolling on a 375px phone.
  • Locked board: on a loss the grid gets pointer-events: none and every button is disabled, so neither mouse nor keyboard can change anything until you press New game.
  • Floor2/2, 100% passed
  • Middle1/3, 33% passed
  • Top3/4, 75% passed
6 of 9 checks passedfloor 2/2, middle 1/3, top 3/4
  • Passed.
    81 square buttons in a 9 by 9 grid, Mines left starts at 10
    Floor Page test
  • Passed.
    A first click never lands on a mine
    Floor Page test
  • Missed.
    Numbers are right and an empty square opens its neighbourstest group failed
    Middle Page test
  • Missed.
    Right click flags and unflags, the counter follows, a flagged square stays shuttest group failed
    Middle Page test
  • Passed.
    The squares work from the keyboard
    Middle Page test
  • Passed.
    Hitting a mine says Game over, shows all 10 mines as 💣 and stops the board
    Top Page test
  • Passed.
    Opening every safe square shows You win! (and not before)
    Top Page test
  • Missed.
    New game resets the board, counter and messagetest group failed
    Top Page test
  • Passed.
    Fits a 375 px phone with squares of at least 32 px, no sideways scroll
    Top Page test
  • Took 43 s.
  • First words after 2.9 s.
  • Wrote 4,650 tokens, 11 of them thinking.
  • 12¢ at API prices.
All numbers.
Time to finish43 s
Time to first words2.9 s
Input tokens2
Cached tokens1,104
Output tokens4,650
Thinking tokens11
Words in the answer1,415
Price at API rates12¢

AI answer from Opus 5 · max effort · 1 Oct 2026

8 of 9

Screenshot of the page built in this answer, at a desktop width of 1280 pixels
Our screenshot, 1280 by 800. Open it for the full length.
Screenshot of the page built in this answer, at a phone width of 375 pixels
Phone, 375 by 812.
More screenshots (2).
Screenshot of the page built in this answer, at a desktop width of 1280 pixelsScreenshot of the page built in this answer, at a phone width of 375 pixels
Show the code and the answer.

Here's the complete game. Save it as minesweeper.html and open it in any browser — no libraries, no network requests, nothing outside the one file.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light">
<title>Minesweeper — Beginner</title>
<style>
  :root{
    --cell: clamp(32px, 9.4vw, 42px);  /* never smaller than 32px */
    --gap: 2px;
    --ink: #1b1f27;
  }
  *, *::before, *::after{ box-sizing: border-box; }
  html{ -webkit-text-size-adjust: 100%; }
  body{
    margin:0;
    padding:14px 8px 28px;
    min-height:100vh;
    display:flex;
    flex-direction:column;
    align-items:center;
    gap:12px;
    overflow-x:hidden;
    background:#eef1f6;
    color:var(--ink);
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  }
  h1{ margin:0; font-size:1.2rem; letter-spacing:.02em; }
  .wrap{ width:fit-content; max-width:100%; display:flex; flex-direction:column; gap:10px; }
  .panel{ display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
  .counter{
    display:inline-flex; align-items:baseline; gap:6px;
    min-height:40px; padding:9px 12px;
    background:#fff; border:1px solid #cfd6e2; border-radius:8px;
    font-size:.95rem;
  }
  .counter output{
    font-weight:700; font-size:1.05rem;
    font-variant-numeric: tabular-nums;
    min-width:2.5ch; text-align:right;
  }
  .btn{
    font: inherit; font-weight:600;
    min-height:40px; padding:9px 16px;
    border:1px solid #2b5fd9; border-radius:8px;
    background:#2b5fd9; color:#fff;
    cursor:pointer; touch-action: manipulation;
  }
  .btn:hover{ background:#2450b8; }
  .btn:active{ transform: translateY(1px); }
  .btn:focus-visible, .cell:focus-visible{ outline:3px solid #0b57d0; outline-offset:2px; }
  .message{ margin:0; min-height:1.6em; text-align:center; font-size:1.05rem; font-weight:700; }
  .message.win{ color:#1b5e20; }
  .message.lose{ color:#b3261e; }
  .board{
    display:grid;
    grid-template-columns: repeat(9, var(--cell));
    gap: var(--gap);
    padding: var(--gap);
    background:#b7c0cf;
    border-radius:10px;
    user-select:none; -webkit-user-select:none;
    touch-action: manipulation;
  }
  .board.locked .cell{ pointer-events:none; }   /* board stops taking clicks */
  .cell{
    width:var(--cell); height:var(--cell);
    min-width:32px; min-height:32px;            /* big enough to tap */
    display:flex; align-items:center; justify-content:center;
    margin:0; padding:0;
    font-family: inherit;
    font-size: calc(var(--cell) * .5);
    font-weight:700; line-height:1;
    color:var(--ink);
    border:0; border-radius:5px;
    background:#cdd5e2;
    box-shadow: inset 0 -2px 0 rgba(0,0,0,.14), inset 0 2px 0 rgba(255,255,255,.6);
    cursor:pointer;
    touch-action: manipulation;
    -webkit-tap-highlight-color: transparent;
    -webkit-touch-callout: none;
  }
  .cell:hover{ background:#dae0ea; }
  .cell:active{ box-shadow: inset 0 0 0 1px rgba(0,0,0,.12); }
  .cell.open, .cell.open:hover{
    background:#e9edf4; box-shadow: inset 0 0 0 1px #d6dce6; cursor:default;
  }
  .cell.flag, .cell.flag:hover{ background:#f6e7c3; }
  .cell.mine, .cell.mine:hover{ background:#f3d4d2; }
  .cell.boom, .cell.boom:hover{ background:#e5534b; }
  .cell.wrong, .cell.wrong:hover{ background:#ffd9c2; }
  .n1{color:#1565c0}.n2{color:#2e7d32}.n3{color:#c62828}.n4{color:#4527a0}
  .n5{color:#a0522d}.n6{color:#00838f}.n7{color:#37474f}.n8{color:#6d6d6d}
  .help{ margin:0; max-width:36ch; text-align:center; font-size:.82rem; line-height:1.45; color:#4a5261; }
  @media (max-width: 340px){ body{ padding-left:4px; padding-right:4px; } }
</style>
</head>
<body>

  <h1>Minesweeper</h1>

  <div class="wrap">
    <div class="panel">
      <span class="counter"><span id="minesLabel">Mines left</span>:
        <output id="minesLeft" aria-labelledby="minesLabel">10</output></span>
      <button class="btn" id="newGame" type="button">New game</button>
    </div>

    <p class="message" id="message" role="status" aria-live="polite"></p>

    <div class="board" id="board" role="group" aria-label="Minesweeper board, 9 rows by 9 columns"></div>
  </div>

  <p class="help">
    Click a square, or press Enter or Space on a focused square, to open it.
    Right-click (or long-press, or press F) to add and remove a 🚩.
    Arrow keys move between squares.
  </p>

<script>
(function () {
  'use strict';

  var ROWS = 9, COLS = 9, MINES = 10, CELLS = ROWS * COLS;

  var boardEl     = document.getElementById('board');
  var messageEl   = document.getElementById('message');
  var minesLeftEl = document.getElementById('minesLeft');
  var newGameBtn  = document.getElementById('newGame');

  var cells = [];                     // the 81 <button> elements
  var mine, opened, flagged, counts;  // board state
  var started, gameOver, revealAll, explodedIndex, openedCount;

  var pressTimer = null, suppressTimer = null, suppressClick = false;

  function idx(r, c) { return r * COLS + c; }
  function rowOf(i)  { return Math.floor(i / COLS); }
  function colOf(i)  { return i % COLS; }

  function eachNeighbour(i, fn) {
    var r = rowOf(i), c = colOf(i), dr, dc, nr, nc;
    for (dr = -1; dr <= 1; dr++) {
      for (dc = -1; dc <= 1; dc++) {
        if (dr === 0 && dc === 0) continue;
        nr = r + dr; nc = c + dc;
        if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) continue;
        fn(idx(nr, nc));
      }
    }
  }

  /* ---------- setup ---------- */

  function build() {
    var frag = document.createDocumentFragment();
    for (var i = 0; i < CELLS; i++) {
      var b = document.createElement('button');
      b.type = 'button';
      b.className = 'cell';
      b.dataset.i = i;
      b.addEventListener('click', onOpen);                 // mouse + Enter/Space
      b.addEventListener('contextmenu', onContext);        // right click = flag
      b.addEventListener('keydown', onKeyDown);            // arrows + F
      b.addEventListener('touchstart', onTouchStart, { passive: true });
      b.addEventListener('touchend', cancelPress);
      b.addEventListener('touchmove', cancelPress, { passive: true });
      b.addEventListener('touchcancel', cancelPress);
      cells.push(b);
      frag.appendChild(b);
    }
    boardEl.appendChild(frag);
    boardEl.addEventListener('contextmenu', function (e) { e.preventDefault(); });
    newGameBtn.addEventListener('click', newGame);
  }

  function newGame() {
    mine    = new Array(CELLS).fill(false);
    opened  = new Array(CELLS).fill(false);
    flagged = new Array(CELLS).fill(false);
    counts  = new Array(CELLS).fill(0);
    started = false;
    gameOver = false;
    revealAll = false;
    explodedIndex = -1;
    openedCount = 0;
    messageEl.textContent = '';
    messageEl.className = 'message';
    boardEl.classList.remove('locked');
    render();
    updateCounter();
  }

  /* Mines are laid after the first click, never on the square you clicked
     (or its neighbours), so the first move is always safe. */
  function placeMines(safe) {
    var blocked = {}, pool = [], i, k, j, t, n;
    blocked[safe] = true;
    eachNeighbour(safe, function (nb) { blocked[nb] = true; });
    for (i = 0; i < CELLS; i++) if (!blocked[i]) pool.push(i);

    for (k = 0; k < MINES; k++) {                   // partial Fisher–Yates
      j = k + Math.floor(Math.random() * (pool.length - k));
      t = pool[k]; pool[k] = pool[j]; pool[j] = t;
      mine[pool[k]] = true;
    }
    for (i = 0; i < CELLS; i++) {
      n = 0;
      eachNeighbour(i, function (nb) { if (mine[nb]) n++; });
      counts[i] = n;
    }
  }

  /* ---------- playing ---------- */

  function onOpen() {
    if (suppressClick) { suppressClick = false; return; }  // long-press already flagged
    var i = Number(this.dataset.i);
    if (gameOver || opened[i] || flagged[i]) return;       // a flag blocks opening
    if (!started) { placeMines(i); started = true; }
    if (mine[i]) { lose(i); return; }
    floodOpen(i);
    if (openedCount === CELLS - MINES) win();
    render();
    updateCounter();
  }

  function floodOpen(start) {
    var stack = [start], i;
    while (stack.length) {
      i = stack.pop();
      if (opened[i] || flagged[i] || mine[i]) continue;
      opened[i] = true;
      openedCount++;
      if (counts[i] === 0) {
        eachNeighbour(i, function (nb) {
          if (!opened[nb] && !flagged[nb]) stack.push(nb);
        });
      }
    }
  }

  function toggleFlag(i) {
    if (gameOver || opened[i]) return;
    flagged[i] = !flagged[i];
    render();
    updateCounter();
  }

  function win() {
    gameOver = true;
    for (var i = 0; i < CELLS; i++) if (mine[i]) flagged[i] = true;
    boardEl.classList.add('locked');
    messageEl.textContent = 'You win!';
    messageEl.className = 'message win';
  }

  function lose(i) {
    gameOver = true;
    revealAll = true;
    explodedIndex = i;
    opened[i] = true;
    boardEl.classList.add('locked');
    messageEl.textContent = 'Game over';
    messageEl.className = 'message lose';
    render();
    updateCounter();
  }

  /* ---------- drawing ---------- */

  function render() {
    for (var i = 0; i < CELLS; i++) {
      var b = cells[i], cls = 'cell', text = '', state = '';

      if (revealAll && mine[i]) {
        cls += ' open mine' + (i === explodedIndex ? ' boom' : '');
        text = '💣';
        state = 'mine';
      } else if (revealAll && flagged[i]) {
        cls += ' wrong';
        text = '🚩';
        state = 'flag, no mine here';
      } else if (opened[i]) {
        cls += ' open';
        if (counts[i] > 0) {
          cls += ' n' + counts[i];
          text = String(counts[i]);
          state = counts[i] + (counts[i] === 1 ? ' mine nearby' : ' mines nearby');
        } else {
          state = 'empty';
        }
      } else if (flagged[i]) {
        cls += ' flag';
        text = '🚩';
        state = 'flagged';
      } else {
        state = 'unopened';
      }

      b.className = cls;
      b.textContent = text;
      b.setAttribute('aria-label',
        'Row ' + (rowOf(i) + 1) + ' column ' + (colOf(i) + 1) + ', ' + state);
      if (opened[i] || revealAll) b.setAttribute('aria-disabled', 'true');
      else b.removeAttribute('aria-disabled');
    }
  }

  function updateCounter() {
    var f = 0;
    for (var i = 0; i < CELLS; i++) if (flagged[i]) f++;
    minesLeftEl.textContent = String(MINES - f);
  }

  /* ---------- input ---------- */

  function onContext(e) {
    e.preventDefault();
    cancelPress();
    if (suppressClick) return;        // long-press timer already handled it
    toggleFlag(Number(this.dataset.i));
  }

  function onKeyDown(e) {
    if (e.altKey || e.ctrlKey || e.metaKey) return;
    var i = Number(this.dataset.i), r = rowOf(i), c = colOf(i);

    if (e.key === 'f' || e.key === 'F') {        // flag from the keyboard
      e.preventDefault();
      toggleFlag(i);
      return;
    }
    switch (e.key) {
      case 'ArrowUp':    r--; break;
      case 'ArrowDown':  r++; break;
      case 'ArrowLeft':  c--; break;
      case 'ArrowRight': c++; break;
      case 'Home':       c = 0; break;
      case 'End':        c = COLS - 1; break;
      default: return;                            // Enter/Space fire click natively
    }
    e.preventDefault();
    if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return;
    cells[idx(r, c)].focus();
  }

  function onTouchStart() {
    var b = this;
    cancelPress();
    pressTimer = window.setTimeout(function () {
      pressTimer = null;
      toggleFlag(Number(b.dataset.i));
      suppressClick = true;
      window.clearTimeout(suppressTimer);
      suppressTimer = window.setTimeout(function () { suppressClick = false; }, 800);
      if (navigator.vibrate) navigator.vibrate(12);
    }, 500);
  }

  function cancelPress() {
    if (pressTimer !== null) { window.clearTimeout(pressTimer); pressTimer = null; }
  }

  build();
  newGame();
})();
</script>
</body>
</html>

A few notes on how it meets your list:

  • Squares are real buttons, never disabled, so Tab reaches every one and Enter/Space opens the focused square. Arrow keys and Home/End move around the grid, and F flags, since right-click isn't available from a keyboard. Each button carries an aria-label like "Row 3 column 4, 2 mines nearby".
  • First click is safe: mines are laid only after your first click, avoiding that square and its eight neighbours, so your opening move always clears an area. If you'd rather it only protect the clicked square, delete the eachNeighbour(safe, …) line in placeMines.
  • Flags: right-click toggles; onOpen returns early on a flagged square, and the flood fill skips flagged squares too, so a flag genuinely locks a square until you remove it.
  • Counter shows 10 − flags; it can go negative if you over-flag, which is standard. On a win the remaining mines are flagged for you, so it lands on 0.
  • Game over reveals every mine as 💣 (the one you hit turns red), marks any wrong flags, and the board gets a locked class that sets pointer-events: none, backed up by a gameOver guard in every handler.
  • Phone fit: cell size is clamp(32px, 9.4vw, 42px), so at 375px wide the whole board is about 337px plus 8px padding each side — no sideways scrolling, and taps are at least 32px. Long-press also places a flag on touch screens.
  • Floor2/2, 100% passed
  • Middle2/3, 67% passed
  • Top4/4, 100% passed
8 of 9 checks passedfloor 2/2, middle 2/3, top 4/4
  • Passed.
    81 square buttons in a 9 by 9 grid, Mines left starts at 10
    Floor Page test
  • Passed.
    A first click never lands on a mine
    Floor Page test
  • Missed.
    Numbers are right and an empty square opens its neighbourstest group failed
    Middle Page test
  • Passed.
    Right click flags and unflags, the counter follows, a flagged square stays shut
    Middle Page test
  • Passed.
    The squares work from the keyboard
    Middle Page test
  • Passed.
    Hitting a mine says Game over, shows all 10 mines as 💣 and stops the board
    Top Page test
  • Passed.
    Opening every safe square shows You win! (and not before)
    Top Page test
  • Passed.
    New game resets the board, counter and message
    Top Page test
  • Passed.
    Fits a 375 px phone with squares of at least 32 px, no sideways scroll
    Top Page test
  • Took 4 min 19 s.
  • First words after 3 min 30 s.
  • Wrote 23,979 tokens, 18,075 of them thinking.
  • 61¢ at API prices.
All numbers.
Time to finish4 min 19 s
Time to first words3 min 30 s
Input tokens2
Cached tokens1,104
Output tokens23,979
Thinking tokens18,075
Words in the answer1,604
Price at API rates61¢
B05Fix three layout bugs on a garden pageLow 7/7Max 7/7Same

What it asks for. Pastes a community garden club's web page and describes three problems on phones: the page scrolls sideways, section headings end up hidden under the sticky header after tapping the menu links, and three event cards are squashed instead of sitting one per row. It asks for the whole page back with all three fixed, leaving the laptop layout as it is and the header still sticky.

Both settings passed 7 of 7 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

7 of 7

Screenshot of the page built in this answer, at a desktop width of 1280 pixels
Our screenshot, 1280 by 800. Open it for the full length.
Screenshot of the page built in this answer, at a phone width of 375 pixels
Phone, 375 by 812.
Show the code and the answer.

Three separate causes, and all three are fixable in CSS only.

What was wrong

  1. Sideways scroll: .fees has a hard width: 720px. On a 390px-wide phone that forces the page wider than the screen. The long email address in the contact box is a second, smaller culprit — it can't break, so it also pushes the layout out. Fixed by making the table full-width inside a scrollable wrapper (so only the table scrolls, not the page) and letting the email wrap.
  2. Headings hidden under the header: anchor jumps scroll the target to the very top of the viewport, which is behind your sticky header. Fixed with scroll-padding-top on html plus scroll-margin-top on the sections — no JavaScript needed.
  3. Squashed cards: grid-template-columns: repeat(3, 1fr) is unconditional. A media query drops it to one column on narrow screens (and two on tablets, which you can delete if you'd rather it went straight to one).

The laptop layout is untouched — all the changes are either inside a max-width: 720px media query or are no-ops at wide widths. The header stays sticky.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Thistlebank Allotments</title>
<style>
  * { box-sizing: border-box; }

  /* Fix 2: keep anchor targets clear of the sticky header */
  html { scroll-padding-top: 5rem; }

  body {
    margin: 0;
    font-family: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
    line-height: 1.55;
    color: #23301f;
    background: #f7f4ea;
  }
  .wrap { max-width: 960px; margin: 0 auto; padding: 0 1rem; }
  .site-header {
    position: sticky;
    top: 0;
    z-index: 10;
    background: #2f5d34;
    color: #fff;
    box-shadow: 0 1px 6px rgba(0, 0, 0, .25);
  }
  .site-header .wrap {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: .25rem 1.5rem;
    padding-top: .9rem;
    padding-bottom: .9rem;
  }
  .brand { font-size: 1.15rem; font-weight: 700; letter-spacing: .01em; }
  .site-header nav { display: flex; flex-wrap: wrap; gap: 1.1rem; }
  .site-header a { color: #fff; text-decoration: none; font-weight: 500; }
  .site-header a:hover { text-decoration: underline; }
  .hero { background: #dfe9c8; padding: 4rem 0 3.5rem; }
  .hero h1 { margin: 0 0 .5rem; font-size: 2.4rem; line-height: 1.15; color: #1f4424; }
  .hero p { margin: 0; max-width: 34rem; font-size: 1.15rem; }

  section.block { padding: 2rem 0; }
  /* Fix 2 (belt and braces, for browsers that ignore scroll-padding) */
  section.block[id] { scroll-margin-top: 5rem; }
  section.block h2 { margin: 0 0 1rem; font-size: 1.7rem; color: #1f4424; }

  .cards {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 1.25rem;
  }
  .card { background: #fff; border: 1px solid #d8d2bd; border-radius: 10px; padding: 1.1rem 1.2rem; }
  .card h3 { margin: 0 0 .2rem; font-size: 1.15rem; }
  .card .when { margin: 0 0 .6rem; font-size: .9rem; color: #5b6650; font-weight: 600; }
  .card p { margin: 0; }

  /* Fix 1: the table no longer forces a fixed 720px page width.
     It keeps its comfortable size on wide screens, and on narrow
     screens only this wrapper scrolls, not the whole page. */
  .table-scroll {
    max-width: 100%;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
  }
  .fees {
    width: 100%;
    min-width: 36rem;
    border-collapse: collapse;
    background: #fff;
  }
  .fees th, .fees td { text-align: left; padding: .7rem .9rem; border-bottom: 1px solid #e2dcc7; }
  .fees th { background: #e8e2cc; font-size: .95rem; }
  .note { margin-top: 1rem; font-size: .95rem; color: #4a5642; }

  .contact-box { background: #fff; border: 1px solid #d8d2bd; border-radius: 10px; padding: 1.2rem 1.4rem; }
  .contact-box p { margin: 0 0 .5rem; }
  /* Fix 1: let the long address wrap instead of widening the page */
  .email { font-size: 1.1rem; font-weight: 600; overflow-wrap: anywhere; }
  .email a { color: #1f4424; }

  footer { background: #23301f; color: #d8dccc; padding: 1.5rem 0; font-size: .9rem; }

  /* ---- Phones and small tablets only. Laptop view is unchanged. ---- */
  @media (max-width: 900px) {
    .cards { grid-template-columns: repeat(2, 1fr); }
  }
  @media (max-width: 640px) {
    /* Fix 3: one event card per row */
    .cards { grid-template-columns: 1fr; }
    .hero { padding: 2.5rem 0 2rem; }
    .hero h1 { font-size: 1.9rem; }
    .hero p { font-size: 1.05rem; }
    .site-header .wrap { justify-content: flex-start; gap: .4rem 1rem; }
    .fees { min-width: 30rem; }
    .fees th, .fees td { padding: .6rem .7rem; }
  }
</style>
</head>
<body>
<header class="site-header">
  <div class="wrap">
    <span class="brand">Thistlebank Allotments</span>
    <nav>
      <a href="#about">About</a>
      <a href="#events">Events</a>
      <a href="#fees">Plot fees</a>
      <a href="#contact">Contact</a>
    </nav>
  </div>
</header>

<main>
  <div class="hero">
    <div class="wrap">
      <h1>Grow something together</h1>
      <p>A friendly community garden on the edge of town, with forty-two plots, a shared tool shed and a kettle that is always on.</p>
    </div>
  </div>

  <section class="block" id="about">
    <div class="wrap">
      <h2>About the garden</h2>
      <p>Thistlebank has been run by its members since the garden opened. Plots are small enough to manage in an evening a week, and there is a shared bed for anyone who is not ready to commit to a plot of their own.</p>
      <p>Beginners are welcome. Long-time growers are usually happy to explain why your beans are doing that.</p>
    </div>
  </section>

  <section class="block" id="events">
    <div class="wrap">
      <h2>Coming up</h2>
      <div class="cards">
        <article class="card">
          <h3>Seed swap</h3>
          <p class="when">First Saturday of the month, 10:00</p>
          <p>Bring spare seeds, take what you need. Tea and biscuits in the shed.</p>
        </article>
        <article class="card">
          <h3>Compost clinic</h3>
          <p class="when">Second Wednesday, 18:30</p>
          <p>Learn why the heap smells and what to do about it. Bring gloves.</p>
        </article>
        <article class="card">
          <h3>Autumn bake and dig</h3>
          <p class="when">Last Sunday of October, 13:00</p>
          <p>Tidy the shared beds, then eat cake made from whatever we grew.</p>
        </article>
      </div>
    </div>
  </section>

  <section class="block" id="fees">
    <div class="wrap">
      <h2>Plot fees</h2>
      <div class="table-scroll">
        <table class="fees">
          <thead>
            <tr><th>Plot size</th><th>Best for</th><th>Per year</th><th>Water</th></tr>
          </thead>
          <tbody>
            <tr><td>Small (3 x 5 m)</td><td>Salad and herbs</td><td>$35</td><td>Included</td></tr>
            <tr><td>Medium (5 x 8 m)</td><td>A family of four</td><td>$60</td><td>Included</td></tr>
            <tr><td>Large (8 x 10 m)</td><td>Serious growers</td><td>$95</td><td>Included</td></tr>
            <tr><td>Shared bed</td><td>Trying it out</td><td>$10</td><td>Included</td></tr>
          </tbody>
        </table>
      </div>
      <p class="note">Fees are due on the first of March. Concessions are available; just ask.</p>
    </div>
  </section>

  <section class="block" id="contact">
    <div class="wrap">
      <h2>Contact</h2>
      <div class="contact-box">
        <p>Write to the plot secretary about the waiting list, a visit or anything else.</p>
        <p class="email"><a href="mailto:membershipandplotenquiries@thistlebankallotments.example">membershipandplotenquiries@thistlebankallotments.example</a></p>
        <p>Open days are the first Saturday of every month.</p>
      </div>
    </div>
  </section>
</main>

<footer>
  <div class="wrap">Thistlebank Allotments. Run by members, for members.</div>
</footer>
</body>
</html>

One thing to check: the header can wrap to two lines on very narrow phones, making it taller than the 5rem offset I used. If you see a heading still slightly tucked under it, raise the two 5rem values to 6.5rem and that will clear it.

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top3/3, 100% passed
7 of 7 checks passedfloor 2/2, middle 2/2, top 3/3
  • Passed.
    Returns one whole HTML page that loads
    Floor Page test
  • Passed.
    All the original text is still on the page
    Floor Page test
  • Passed.
    On a 375 px phone the page no longer scrolls sideways
    Middle Page test
  • Passed.
    On a phone the three event cards stack, one per row
    Middle Page test
  • Passed.
    Tapping each top link shows the section heading below the header (phone and laptop)
    Top Page test
  • Passed.
    Laptop layout unchanged: header still sticky, three cards in a row, blocks in the same places
    Top Page test
  • Passed.
    No console errors and nothing loaded from outside
    Top Page test
  • Took 35 s.
  • First words after 4.9 s.
  • Wrote 3,696 tokens, 173 of them thinking.
  • 11¢ at API prices.
All numbers.
Time to finish35 s
Time to first words4.9 s
Input tokens2
Cached tokens3,354
Output tokens3,696
Thinking tokens173
Words in the answer1,047
Price at API rates11¢

AI answer from Opus 5 · max effort · 1 Oct 2026

7 of 7

Screenshot of the page built in this answer, at a desktop width of 1280 pixels
Our screenshot, 1280 by 800. Open it for the full length.
Screenshot of the page built in this answer, at a phone width of 375 pixels
Phone, 375 by 812.
Show the code and the answer.

Three separate culprits, one for each symptom — none of them need to touch the laptop layout.

What's wrong

  1. Sideways scroll: two fixed/unbreakable widths. .fees { width: 720px } is wider than a phone viewport, and the plot-secretary email address is one long unbreakable string that's also wider than ~360px. (.site-header nav was also nowrap, so it could overflow too if text is enlarged.)
  2. Headings under the header: #about etc. scroll the section's top edge to the viewport top, which is behind the sticky header. Needs a scroll offset the size of the header.
  3. Squashed cards: grid-template-columns: repeat(3, 1fr) is unconditional, so it stays three columns at 390px.

The fixed file (changes marked with /* FIX n */):

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Thistlebank Allotments</title>
<style>
  * { box-sizing: border-box; }

  /* Height of the sticky header. Used to offset anchor jumps (FIX 2).
     If you change the brand text or header padding, re-measure and update. */
  :root { --header-h: 3.6rem; }

  html {
    scroll-padding-top: calc(var(--header-h) + .5rem);  /* FIX 2 */
    scroll-behavior: smooth;                            /* optional; delete for instant jumps */
  }
  @media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }
  }

  body {
    margin: 0;
    font-family: system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
    line-height: 1.55;
    color: #23301f;
    background: #f7f4ea;
  }
  .wrap { max-width: 960px; margin: 0 auto; padding: 0 1rem; }
  .site-header {
    position: sticky;
    top: 0;
    z-index: 10;
    background: #2f5d34;
    color: #fff;
    box-shadow: 0 1px 6px rgba(0, 0, 0, .25);
  }
  .site-header .wrap {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: .25rem 1.5rem;
    padding-top: .9rem;
    padding-bottom: .9rem;
  }
  .brand { font-size: 1.15rem; font-weight: 700; letter-spacing: .01em; }

  /* FIX 1c: nav wraps instead of pushing the page sideways */
  .site-header nav { display: flex; flex-wrap: wrap; gap: .25rem 1.1rem; }

  .site-header a { color: #fff; text-decoration: none; font-weight: 500; }
  .site-header a:hover { text-decoration: underline; }
  .hero { background: #dfe9c8; padding: 4rem 0 3.5rem; }
  .hero h1 { margin: 0 0 .5rem; font-size: 2.4rem; line-height: 1.15; color: #1f4424; }
  .hero p { margin: 0; max-width: 34rem; font-size: 1.15rem; }
  section.block { padding: 2rem 0; }
  section.block h2 { margin: 0 0 1rem; font-size: 1.7rem; color: #1f4424; }

  /* FIX 3: one card per row on phones, two on tablets, three from 768px up */
  .cards {
    display: grid;
    grid-template-columns: 1fr;
    gap: 1.25rem;
  }
  @media (min-width: 36rem) { .cards { grid-template-columns: repeat(2, 1fr); } }
  @media (min-width: 48rem) { .cards { grid-template-columns: repeat(3, 1fr); } }

  .card { background: #fff; border: 1px solid #d8d2bd; border-radius: 10px; padding: 1.1rem 1.2rem; }
  .card h3 { margin: 0 0 .2rem; font-size: 1.15rem; }
  .card .when { margin: 0 0 .6rem; font-size: .9rem; color: #5b6650; font-weight: 600; }
  .card p { margin: 0; }

  /* FIX 1a: the table keeps its 720px look on a laptop, but on a narrow
     screen it scrolls inside its own box instead of moving the whole page. */
  .table-scroll {
    max-width: 720px;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
  }
  .table-scroll:focus-visible { outline: 2px solid #2f5d34; outline-offset: 2px; }
  .fees { width: 100%; min-width: 560px; border-collapse: collapse; background: #fff; }
  .fees th, .fees td { text-align: left; padding: .7rem .9rem; border-bottom: 1px solid #e2dcc7; }
  .fees th { background: #e8e2cc; font-size: .95rem; }
  .note { margin-top: 1rem; font-size: .95rem; color: #4a5642; }
  .contact-box { background: #fff; border: 1px solid #d8d2bd; border-radius: 10px; padding: 1.2rem 1.4rem; }
  .contact-box p { margin: 0 0 .5rem; }
  .email { font-size: 1.1rem; font-weight: 600; }

  /* FIX 1b: let the long email address break rather than overflow */
  .email a { color: #1f4424; overflow-wrap: anywhere; }

  footer { background: #23301f; color: #d8dccc; padding: 1.5rem 0; font-size: .9rem; }

  /* Phone-only adjustments. Nothing in here applies above 512px. */
  @media (max-width: 32rem) {
    /* Header is deliberately two rows here, so its height is predictable (FIX 2) */
    :root { --header-h: 5.5rem; }
    .site-header .wrap {
      flex-direction: column;
      align-items: flex-start;
      gap: .25rem;
    }
    /* Optional polish: the desktop hero is a bit tall on a phone. Delete if you like. */
    .hero { padding: 2.5rem 0 2.25rem; }
    .hero h1 { font-size: 2rem; }
    .hero p { font-size: 1.05rem; }
  }
</style>
</head>
<body>
<header class="site-header">
  <div class="wrap">
    <span class="brand">Thistlebank Allotments</span>
    <nav>
      <a href="#about">About</a>
      <a href="#events">Events</a>
      <a href="#fees">Plot fees</a>
      <a href="#contact">Contact</a>
    </nav>
  </div>
</header>

<main>
  <div class="hero">
    <div class="wrap">
      <h1>Grow something together</h1>
      <p>A friendly community garden on the edge of town, with forty-two plots, a shared tool shed and a kettle that is always on.</p>
    </div>
  </div>

  <section class="block" id="about">
    <div class="wrap">
      <h2>About the garden</h2>
      <p>Thistlebank has been run by its members since the garden opened. Plots are small enough to manage in an evening a week, and there is a shared bed for anyone who is not ready to commit to a plot of their own.</p>
      <p>Beginners are welcome. Long-time growers are usually happy to explain why your beans are doing that.</p>
    </div>
  </section>

  <section class="block" id="events">
    <div class="wrap">
      <h2>Coming up</h2>
      <div class="cards">
        <article class="card">
          <h3>Seed swap</h3>
          <p class="when">First Saturday of the month, 10:00</p>
          <p>Bring spare seeds, take what you need. Tea and biscuits in the shed.</p>
        </article>
        <article class="card">
          <h3>Compost clinic</h3>
          <p class="when">Second Wednesday, 18:30</p>
          <p>Learn why the heap smells and what to do about it. Bring gloves.</p>
        </article>
        <article class="card">
          <h3>Autumn bake and dig</h3>
          <p class="when">Last Sunday of October, 13:00</p>
          <p>Tidy the shared beds, then eat cake made from whatever we grew.</p>
        </article>
      </div>
    </div>
  </section>

  <section class="block" id="fees">
    <div class="wrap">
      <h2>Plot fees</h2>
      <div class="table-scroll" role="region" aria-label="Plot fees" tabindex="0">
        <table class="fees">
          <thead>
            <tr><th>Plot size</th><th>Best for</th><th>Per year</th><th>Water</th></tr>
          </thead>
          <tbody>
            <tr><td>Small (3 x 5 m)</td><td>Salad and herbs</td><td>$35</td><td>Included</td></tr>
            <tr><td>Medium (5 x 8 m)</td><td>A family of four</td><td>$60</td><td>Included</td></tr>
            <tr><td>Large (8 x 10 m)</td><td>Serious growers</td><td>$95</td><td>Included</td></tr>
            <tr><td>Shared bed</td><td>Trying it out</td><td>$10</td><td>Included</td></tr>
          </tbody>
        </table>
      </div>
      <p class="note">Fees are due on the first of March. Concessions are available; just ask. On a small screen, swipe the table sideways to see every column.</p>
    </div>
  </section>

  <section class="block" id="contact">
    <div class="wrap">
      <h2>Contact</h2>
      <div class="contact-box">
        <p>Write to the plot secretary about the waiting list, a visit or anything else.</p>
        <p class="email"><a href="mailto:membershipandplotenquiries@thistlebankallotments.example">membershipandplotenquiries@thistlebankallotments.example</a></p>
        <p>Open days are the first Saturday of every month.</p>
      </div>
    </div>
  </section>
</main>

<footer>
  <div class="wrap">Thistlebank Allotments. Run by members, for members.</div>
</footer>
</body>
</html>

Why the laptop view is unchanged

  • The table sits in a 720px-max box and is width: 100%, so it still renders at exactly 720px with no scrollbar.
  • The three-column card grid is restored at 768px and above.
  • The header-stacking, hero and --header-h changes all live inside @media (max-width: 32rem), which no laptop hits.
  • Only the optional scroll-behavior: smooth is a visible difference on desktop — delete that one line if you'd rather keep the instant jump.

Two things worth knowing

  • I deliberately didn't "fix" the sideways scroll with overflow-x: hidden on body. It only hides the symptom, and because it makes the body a scroll container it can break position: sticky for things inside it — exactly what you want to keep.
  • If you ever change the header (longer club name, different padding, an extra nav link), re-measure it and update --header-h in the two places. To find any future horizontal-overflow culprit, paste this into the browser console: document.querySelectorAll('*').forEach(el => { if (el.getBoundingClientRect().right > document.documentElement.clientWidth + 1) console.log(el); }) — it lists every element poking past the right edge.

Worth a quick check at 320px, 390px and 768px wide in your browser's device toolbar, plus one real tap through all four nav links on the phone itself.

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top3/3, 100% passed
7 of 7 checks passedfloor 2/2, middle 2/2, top 3/3
  • Passed.
    Returns one whole HTML page that loads
    Floor Page test
  • Passed.
    All the original text is still on the page
    Floor Page test
  • Passed.
    On a 375 px phone the page no longer scrolls sideways
    Middle Page test
  • Passed.
    On a phone the three event cards stack, one per row
    Middle Page test
  • Passed.
    Tapping each top link shows the section heading below the header (phone and laptop)
    Top Page test
  • Passed.
    Laptop layout unchanged: header still sticky, three cards in a row, blocks in the same places
    Top Page test
  • Passed.
    No console errors and nothing loaded from outside
    Top Page test
  • Took 2 min 49 s.
  • First words after 2 min 12 s.
  • Wrote 14,627 tokens, 10,569 of them thinking.
  • 39¢ at API prices.
All numbers.
Time to finish2 min 49 s
Time to first words2 min 12 s
Input tokens2
Cached tokens3,354
Output tokens14,627
Thinking tokens10,569
Words in the answer1,248
Price at API rates39¢

Code

F01Load more shows some posts twice and skips othersLow 7/7Max 7/7Same

What it asks for. Pastes a Python function that pages through a community board's posts, newest first, and reports that some posts show up twice while others never appear, more often on busy days. Many posts share exactly the same timestamp. It asks for the cause and a fixed function with the same name, arguments and return shape, where posts with the same time come higher id first.

Both settings passed 7 of 7 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

7 of 7

What was wrong

  1. The cursor was only created_at, which isn't unique. With WHERE created_at <= ?, every post sharing that second gets re-served on the next page → duplicates. (And if you'd used <, you'd skip the rest of that second instead → missing posts.) Busy days = more ties = more breakage, which matches what users report.
  2. The sort order was ambiguous. ORDER BY created_at DESC alone leaves tied rows in whatever order SQLite feels like, so the same row can land on page 1 in one query and page 2 in the next — another source of both duplicates and omissions.
  3. The cursor was taken from the last row of the page but compared against the whole tie group, so it could never advance past a tie group larger than limit (a stuck page).
  4. Minor: rows[-1] would raise IndexError if the table were empty and has_more were somehow true; and the plain-integer cursor wasn't really opaque.

The fix is a composite keyset cursor on (created_at, id) — a total order, since ids are unique — with the matching strict tuple comparison.

import base64
import sqlite3

MAX_LIMIT = 100
COLUMNS = "id, author, body, created_at"


def _encode_cursor(created_at, post_id):
    raw = f"{int(created_at)}:{int(post_id)}".encode("ascii")
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def _decode_cursor(cursor):
    s = str(cursor)
    pad = "=" * (-len(s) % 4)
    try:
        raw = base64.urlsafe_b64decode(s + pad).decode("ascii")
        created_at, post_id = raw.split(":")
        return int(created_at), int(post_id)
    except Exception as exc:
        raise ValueError("invalid cursor") from exc


def fetch_page(conn, cursor=None, limit=20):
    """Return (rows, next_cursor): one page of the board, newest first.

    rows is a list of (id, author, body, created_at) tuples, ordered by
    created_at descending and, within equal created_at, by id descending.
    next_cursor is an opaque string the caller hands back to get the next
    page, or None when there are no more posts.

    posts(id INTEGER PRIMARY KEY, author TEXT, body TEXT, created_at INTEGER)
    created_at is unix seconds.
    """
    limit = max(1, min(int(limit), MAX_LIMIT))

    order = "ORDER BY created_at DESC, id DESC"

    if cursor is None:
        cur = conn.execute(
            f"SELECT {COLUMNS} FROM posts {order} LIMIT ?",
            (limit + 1,),
        )
    else:
        last_created_at, last_id = _decode_cursor(cursor)
        # Strictly "older" than the cursor row in (created_at, id) order.
        cur = conn.execute(
            f"SELECT {COLUMNS} FROM posts "
            "WHERE created_at < ? OR (created_at = ? AND id < ?) "
            f"{order} LIMIT ?",
            (last_created_at, last_created_at, last_id, limit + 1),
        )

    rows = cur.fetchall()
    has_more = len(rows) > limit
    rows = rows[:limit]

    next_cursor = None
    if has_more and rows:
        last = rows[-1]
        next_cursor = _encode_cursor(last[3], last[0])  # (created_at, id)

    return rows, next_cursor

Notes:

  • Each page resumes strictly after a specific row, not after a timestamp, so ties are split cleanly: no row is ever returned twice and none is skipped.
  • The cursor is base64 of created_at:id — opaque to the app, which just passes it back. Old plain-integer cursors won't decode; they'll raise ValueError, so if you have live clients holding them, treat a decode failure as "start from the beginning" instead of raising.
  • For performance, add an index matching the sort: CREATE INDEX IF NOT EXISTS posts_feed ON posts(created_at DESC, id DESC); Without it, SQLite sorts the whole table for every page.
  • One inherent property of keyset paging: posts created after you started scrolling won't appear in pages you've already passed. That's usually what you want for a feed.
  • Floor2/2, 100% passed
  • Middle3/3, 100% passed
  • Top2/2, 100% passed
7 of 7 checks passedfloor 2/2, middle 3/3, top 2/2
  • Passed.
    The first page comes back correct, newest first
    Floor Tested
  • Passed.
    Keeps the return shape: string cursor, None at the end
    Floor Tested
  • Passed.
    Paging through everything shows each post exactly once
    Middle Tested
  • Passed.
    Order is newest first and, on ties, higher id first
    Middle Tested
  • Passed.
    Explains that shared created_at values cause the problem
    Middle Tested
  • Passed.
    Still correct at page sizes 1, 2 and 3
    Top Tested
  • Passed.
    New posts added at the top mid-scroll cause no repeats or gaps
    Top Tested
  • Took 19 s.
  • First words after 4.0 s.
  • Wrote 1,638 tokens, 138 of them thinking.
  • 5.0¢ at API prices.
All numbers.
Time to finish19 s
Time to first words4.0 s
Input tokens2
Cached tokens1,401
Output tokens1,638
Thinking tokens138
Words in the answer558
Price at API rates5.0¢

AI answer from Opus 5 · max effort · 1 Oct 2026

7 of 7

What was wrong

Two bugs, both rooted in the same thing: (created_at) alone is not a unique key, so it can't be a cursor.

  1. The sort order is not total. ORDER BY created_at DESC leaves posts that share a created_at in whatever order SQLite happens to produce. That order isn't guaranteed to be stable between the two queries that straddle a page boundary, so tied posts can land on the wrong side of the cut and get shown twice or skipped entirely.
  2. The cursor is ambiguous, and <= double-counts. next_cursor is just the last row's timestamp, which says nothing about where inside that timestamp you stopped. With created_at <= ?, every post sharing the boundary timestamp is re-fetched on the next page — guaranteed duplicates. Switching to < wouldn't help either; it would drop the rest of that timestamp's posts instead.

Both failure modes scale with the number of ties, which is exactly why it gets worse on busy days (and why the batch importer, which stamps many posts with the same created_at, makes it bad).

Note that id can't be the sole cursor either, since the importer back-fills and ids don't follow created_at order. The fix is keyset pagination on the composite key (created_at DESC, id DESC) — unique because id is a primary key — with both halves carried in the cursor.

Fixed function

import base64
import binascii
import sqlite3

MAX_LIMIT = 100
COLUMNS = "id, author, body, created_at"


def _encode_cursor(created_at, post_id):
    raw = f"{int(created_at)}:{int(post_id)}".encode("ascii")
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def _decode_cursor(cursor):
    text = cursor.decode("ascii") if isinstance(cursor, bytes) else str(cursor)
    padded = text + "=" * (-len(text) % 4)
    try:
        raw = base64.urlsafe_b64decode(padded.encode("ascii")).decode("ascii")
        created_at, post_id = raw.split(":", 1)
        return int(created_at), int(post_id)
    except (binascii.Error, UnicodeDecodeError, ValueError):
        raise ValueError("invalid cursor")


def fetch_page(conn, cursor=None, limit=20):
    """Return (rows, next_cursor): one page of the board, newest first.

    rows is a list of (id, author, body, created_at) tuples, ordered by
    created_at descending and, within a single created_at, by id descending.
    next_cursor is an opaque string the caller hands back to get the next
    page, or None when there are no more posts.

    posts(id INTEGER PRIMARY KEY, author TEXT, body TEXT, created_at INTEGER)
    created_at is unix seconds.
    """
    limit = max(1, min(int(limit), MAX_LIMIT))

    if cursor is None:
        cur = conn.execute(
            f"SELECT {COLUMNS} FROM posts "
            "ORDER BY created_at DESC, id DESC "
            "LIMIT ?",
            (limit + 1,),
        )
    else:
        last_created_at, last_id = _decode_cursor(cursor)
        cur = conn.execute(
            f"SELECT {COLUMNS} FROM posts "
            "WHERE created_at < ? OR (created_at = ? AND id < ?) "
            "ORDER BY created_at DESC, id DESC "
            "LIMIT ?",
            (last_created_at, last_created_at, last_id, limit + 1),
        )

    rows = cur.fetchall()
    has_more = len(rows) > limit
    rows = rows[:limit]

    next_cursor = None
    if has_more and rows:
        next_cursor = _encode_cursor(rows[-1][3], rows[-1][0])

    return rows, next_cursor

Notes

  • Ordering within a timestamp is id DESC, so ties put the higher id first, as you asked. The cursor's strict < comparison is now correct precisely because (created_at, id) is unique: no row can compare equal to the boundary except the boundary row itself.
  • The cursor stays opaque. It's base64url of "created_at:id" with padding stripped, so it's URL- and JSON-safe and the app has nothing to parse. A corrupt or tampered cursor raises ValueError rather than silently paging from a wrong spot — worth catching at your request handler and returning a 400.
  • Add a matching index, or you'll do a sort on every page:
    CREATE INDEX IF NOT EXISTS posts_created_at_id ON posts(created_at DESC, id DESC);
    SQLite 3.8.3+ honours per-column DESC in indexes; on older versions a plain (created_at, id) index still gets scanned backwards.
  • Posts arriving mid-scroll no longer disturb pages already served. Keyset pagination walks strictly away from the cursor, so a new post with a newer created_at simply doesn't appear in later pages — the right behaviour for a newest-first feed, and a second thing OFFSET-style paging gets wrong. A back-filled import is visible on later pages, since its old timestamp puts it ahead of the cursor; that's also what you want.
  • If you ever want to page the other direction, flip both comparisons to > and both sort keys to ASC, keeping the pair together — mixing one of each reintroduces exactly this bug.
  • Floor2/2, 100% passed
  • Middle3/3, 100% passed
  • Top2/2, 100% passed
7 of 7 checks passedfloor 2/2, middle 3/3, top 2/2
  • Passed.
    The first page comes back correct, newest first
    Floor Tested
  • Passed.
    Keeps the return shape: string cursor, None at the end
    Floor Tested
  • Passed.
    Paging through everything shows each post exactly once
    Middle Tested
  • Passed.
    Order is newest first and, on ties, higher id first
    Middle Tested
  • Passed.
    Explains that shared created_at values cause the problem
    Middle Tested
  • Passed.
    Still correct at page sizes 1, 2 and 3
    Top Tested
  • Passed.
    New posts added at the top mid-scroll cause no repeats or gaps
    Top Tested
  • Took 39 s.
  • First words after 18 s.
  • Wrote 3,418 tokens, 1,504 of them thinking.
  • 9.4¢ at API prices.
All numbers.
Time to finish39 s
Time to first words18 s
Input tokens2
Cached tokens1,401
Output tokens3,418
Thinking tokens1,504
Words in the answer710
Price at API rates9.4¢
F02The search box shows results for an old queryLow 7/7Max 7/7Same

What it asks for. Pastes a TypeScript controller for a search-as-you-type box with two bugs: typing fast can leave results for an earlier query on screen, and the loading spinner disappears too early. The API client cannot cancel requests. It asks for the whole fixed file with the same exports, the spinner on exactly while the newest search is loading, and code that Node can run with its built-in TypeScript support.

Both settings passed 7 of 7 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

7 of 7

The fixed file

export interface SearchOptions {
  // The API client. It returns a Promise and can't be cancelled.
  fetchResults: (query: string) => Promise<string[]>;
  // Draws the list. Pass an error as the second argument to show the error state.
  render: (results: string[], error?: Error) => void;
  // Shows or hides the spinner.
  setLoading: (loading: boolean) => void;
}

export function createSearch({ fetchResults, render, setLoading }: SearchOptions) {
  // Monotonic id of the most recent onInput call. Anything older is stale.
  let latest = 0;

  return {
    // Called on every keystroke with the current text of the box.
    async onInput(raw: string): Promise<void> {
      const query = raw.trim();
      const id = ++latest;

      if (query === "") {
        render([]);
        setLoading(false);
        return;
      }

      setLoading(true);
      try {
        const results = await fetchResults(query);
        if (id !== latest) return;
        render(results);
      } catch (err) {
        if (id !== latest) return;
        render([], err instanceof Error ? err : new Error(String(err)));
      }
      setLoading(false);
    },
  };
}

What was wrong

Stale results overwriting fresh ones. Every keystroke started a request, and whichever request resolved last won — which isn't necessarily the newest one, since a slow early request can land after a fast later one. Now each call takes a ticket (id = ++latest), and after awaiting it checks whether it is still the newest call. If not, it returns without touching the list or the spinner. Since the API can't be cancelled, the old request still runs to completion; we just ignore its outcome.

The spinner turning off too early. setLoading(false) was in a finally, so any request finishing — including an obsolete one — switched the spinner off while the newest request was still in flight. Moving it out of finally and placing it after the staleness check means only the newest request can turn it off, and it does so exactly when its results or error are rendered.

Emptying the box. The empty branch previously rendered [] but never touched the spinner, so if you cleared the box mid-request you were left with a spinner that only stopped when the orphaned request finished. It now bumps latest (invalidating every request in flight) and calls setLoading(false) explicitly, so the list clears, the spinner goes off, and an older request landing afterwards is discarded.

No enums or other transform-requiring syntax, the export, its options and the onInput signature are unchanged, and fetchResults is still called on every input.

One thing worth knowing: this relies on onInput calls being started in keystroke order, which is the case when it's wired straight to an input event. Debouncing later will slot in cleanly on top of this — the ticket check stays correct either way.

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top3/3, 100% passed
7 of 7 checks passedfloor 2/2, middle 2/2, top 3/3
  • Passed.
    The file loads under type stripping and a plain search works
    Floor Tested
  • Passed.
    A failing search still shows the error and hides the spinner
    Floor Tested
  • Passed.
    Only the newest query's results are left on screen
    Middle Tested
  • Passed.
    The spinner stays on until the newest request finishes, then goes off
    Middle Tested
  • Passed.
    An old request that fails does not touch the newer results
    Top Tested
  • Passed.
    Emptying the box clears the list, and late answers don't bring it back
    Top Tested
  • Passed.
    If the newest search fails, the error is shown and stays
    Top Tested
  • Took 14 s.
  • First words after 2.7 s.
  • Wrote 982 tokens, 22 of them thinking.
  • 3.3¢ at API prices.
All numbers.
Time to finish14 s
Time to first words2.7 s
Input tokens2
Cached tokens1,352
Output tokens982
Thinking tokens22
Words in the answer448
Price at API rates3.3¢

AI answer from Opus 5 · max effort · 1 Oct 2026

7 of 7

The fixed file

export interface SearchOptions {
  // The API client. It returns a Promise and can't be cancelled.
  fetchResults: (query: string) => Promise<string[]>;
  // Draws the list. Pass an error as the second argument to show the error state.
  render: (results: string[], error?: Error) => void;
  // Shows or hides the spinner.
  setLoading: (loading: boolean) => void;
}

type Outcome =
  | { ok: true; results: string[] }
  | { ok: false; error: Error };

export function createSearch({ fetchResults, render, setLoading }: SearchOptions) {
  // Every call to onInput takes a ticket. Only the holder of the newest ticket
  // may touch the UI, so responses that come back out of order are dropped
  // instead of overwriting fresher ones.
  let latest = 0;

  // Mirrors what we last told the UI so we don't hand setLoading a value it
  // already has.
  let loadingShown = false;

  const showLoading = (next: boolean): void => {
    if (next !== loadingShown) {
      loadingShown = next;
      setLoading(next);
    }
  };

  return {
    // Called on every keystroke with the current text of the box.
    async onInput(raw: string): Promise<void> {
      const query = raw.trim();
      const ticket = ++latest;

      if (query === "") {
        // Bumping `latest` above has already orphaned anything in flight, so a
        // late response can no longer refill the list or switch the spinner
        // back on.
        render([]);
        showLoading(false);
        return;
      }

      showLoading(true);

      let outcome: Outcome;
      try {
        outcome = { ok: true, results: await fetchResults(query) };
      } catch (err) {
        outcome = {
          ok: false,
          error: err instanceof Error ? err : new Error(String(err)),
        };
      }

      // A newer keystroke (or an emptied box) has taken over. This response is
      // stale: it shows nothing and leaves the spinner alone. The request can't
      // be cancelled, so ignoring the result is all we can do.
      if (ticket !== latest) return;

      if (outcome.ok) {
        render(outcome.results);
      } else {
        render([], outcome.error);
      }

      showLoading(false);
    },
  };
}

What was wrong

1. No request identity, so the last response won instead of the newest one. Each onInput awaited its own fetch and then called render unconditionally. Type ab then abcd; if the server is slower on ab, that response lands second and paints the older query's results over the newer ones. The box and the list disagree until you type again. The fix is the latest counter: a response renders only if its ticket is still the newest, otherwise it is discarded.

2. The spinner was turned off by whichever request happened to finish, not by the newest one. finally { setLoading(false) } ran for every request. Typing fast, the request for keystroke 1 settles while keystroke 3 is still in flight, and the spinner vanishes mid-load. Now only the newest request calls showLoading(false), and it does so in the same synchronous block in which it paints, so the spinner is on exactly while the newest search is outstanding.

3. Emptying the box cleared the list but didn't disown in-flight work. The early return skipped setLoading, so a spinner left on by a previous keystroke stayed on forever, and an older response arriving afterwards would repopulate the list of an empty box. The empty branch now bumps latest (which invalidates everything in flight) and forces the spinner off.

A few deliberate details:

  • Stale rejections are still caught, just not shown. That keeps an old failure from stomping on newer results while avoiding unhandled promise rejections. onInput never rejects, same as before.
  • The fetchResults call stays inside try, so a client that throws synchronously is still handled rather than escaping as a rejected onInput.
  • The requests themselves still run to completion, which is unavoidable without an AbortSignal. Only bandwidth is wasted now, not the UI.
  • loadingShown makes repeated keystrokes idempotent for the spinner: it is switched on once at the start of a burst and off once at the end, instead of being toggled on every key.
  • Only erasable type syntax is used (interface, a union type, annotations), so it runs as-is under Node's type stripping.

Worth a test for the out-of-order case: resolve two fetches manually, settle the first one last, and assert render was called only with the second query's results and that setLoading(false) fired once.

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top3/3, 100% passed
7 of 7 checks passedfloor 2/2, middle 2/2, top 3/3
  • Passed.
    The file loads under type stripping and a plain search works
    Floor Tested
  • Passed.
    A failing search still shows the error and hides the spinner
    Floor Tested
  • Passed.
    Only the newest query's results are left on screen
    Middle Tested
  • Passed.
    The spinner stays on until the newest request finishes, then goes off
    Middle Tested
  • Passed.
    An old request that fails does not touch the newer results
    Top Tested
  • Passed.
    Emptying the box clears the list, and late answers don't bring it back
    Top Tested
  • Passed.
    If the newest search fails, the error is shown and stays
    Top Tested
  • Took 1 min 1 s.
  • First words after 45 s.
  • Wrote 5,155 tokens, 3,594 of them thinking.
  • 14¢ at API prices.
All numbers.
Time to finish1 min 1 s
Time to first words45 s
Input tokens2
Cached tokens1,352
Output tokens5,155
Thinking tokens3,594
Words in the answer712
Price at API rates14¢
F03Some subscriptions renew a day earlyLow 7/7Max 7/7Same

What it asks for. Pastes a JavaScript function that lists a subscription's monthly renewal dates, with its rules in a comment, and reports two symptoms: customers far from UTC renew a day early, and a customer who signed up on the last day of January has been charged on the 28th of every month since. It asks for the function fixed for Node 22 with no libraries, and a short note on what was wrong.

Both settings passed 7 of 7 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

7 of 7

// Monthly renewal dates for a subscription.
//
// signupIso  : the moment the customer signed up, as an ISO string in UTC,
//              e.g. "2025-01-31T23:30:00Z"
// timeZone   : the customer's IANA time zone, e.g. "Pacific/Auckland"
// count      : how many upcoming renewals to return
// returns    : an array of count local calendar dates, "YYYY-MM-DD", in the
//              customer's time zone, earliest first
//
// Rules:
//  - The first renewal is one month after the signup, not on the signup day.
//  - A renewal falls on the same day of the month as the signup's local date
//    in the customer's time zone.
//  - If a month is too short for that day, use its last day.
//  - Always count from the original signup day, never from the previous renewal.
export function renewalDates(signupIso, timeZone, count) {
  // The signup's calendar date as seen in the customer's time zone.
  const parts = new Intl.DateTimeFormat("en-US", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).formatToParts(new Date(signupIso));

  const get = (type) => Number(parts.find((p) => p.type === type).value);
  const year = get("year");
  const month = get("month"); // 1-12
  const anchorDay = get("day"); // never changes

  const daysInMonth = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate();

  const dates = [];
  for (let i = 1; i <= count; i++) {
    const total = month - 1 + i; // months since January of `year`
    const y = year + Math.floor(total / 12);
    const m = (total % 12) + 1;
    const day = Math.min(anchorDay, daysInMonth(y, m));
    dates.push(
      `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`
    );
  }
  return dates;
}

What was wrong:

  1. The time zone was ignored. The code read the day straight off the UTC instant with getUTCDate(). For an Auckland customer who signed up on the morning of the 1st local time, the UTC instant is still the previous day (the 31st), so every renewal landed a day early. The fix formats the signup instant in the customer's zone first to get the true local signup day.
  2. The anchor day drifted. The loop reused the mutated d each time, so once a day was clamped it became the new anchor: a 31st signup clamped to the 28th in February and then stayed on the 28th forever. The fix keeps the original day fixed and computes each month independently from it, clamping only for that month.

A smaller point: the old code also round-tripped through toISOString(), which re-applies UTC and can shift the printed date. Since the output is a local calendar date, the new version builds the string from the year/month/day directly, with no instant conversion at all.

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top3/3, 100% passed
7 of 7 checks passedfloor 2/2, middle 2/2, top 3/3
  • Passed.
    Mid-month signups in UTC get the right dates
    Floor Tested
  • Passed.
    Returns exactly count dates as YYYY-MM-DD, in order
    Floor Tested
  • Passed.
    Uses the customer's local date (Auckland, Kolkata, LA, London)
    Middle Tested
  • Passed.
    Explains both causes: the UTC date and drifting month to month
    Middle Read
  • Passed.
    Signups on the 29th to 31st keep their day after short months
    Top Tested
  • Passed.
    Leap and non-leap Februaries are handled, including 2100
    Top Tested
  • Passed.
    Signups just before or after a clock change get the right local date
    Top Tested
  • Took 15 s.
  • First words after 4.8 s.
  • Wrote 1,222 tokens, 212 of them thinking.
  • 3.9¢ at API prices.
All numbers.
Time to finish15 s
Time to first words4.8 s
Input tokens2
Cached tokens1,375
Output tokens1,222
Thinking tokens212
Words in the answer440
Price at API rates3.9¢

AI answer from Opus 5 · max effort · 1 Oct 2026

7 of 7

Fixed function

// Monthly renewal dates for a subscription.
//
// signupIso  : the moment the customer signed up, as an ISO string in UTC,
//              e.g. "2025-01-31T23:30:00Z"
// timeZone   : the customer's IANA time zone, e.g. "Pacific/Auckland"
// count      : how many upcoming renewals to return
// returns    : an array of count local calendar dates, "YYYY-MM-DD", in the
//              customer's time zone, earliest first
//
// Rules:
//  - The first renewal is one month after the signup, not on the signup day.
//  - A renewal falls on the same day of the month as the signup's local date
//    in the customer's time zone.
//  - If a month is too short for that day, use its last day.
//  - Always count from the original signup day, never from the previous renewal.
export function renewalDates(signupIso, timeZone, count) {
  const instant = new Date(signupIso);
  if (Number.isNaN(instant.getTime())) {
    throw new RangeError(`renewalDates: invalid signupIso ${JSON.stringify(signupIso)}`);
  }
  if (!Number.isInteger(count) || count < 0) {
    throw new RangeError(`renewalDates: count must be a non-negative integer, got ${count}`);
  }

  // The anchor is the signup's *local* calendar date in the customer's zone,
  // which can be a different day from its UTC date. Intl throws a RangeError
  // of its own if timeZone is not a recognised IANA name.
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone,
    calendar: 'gregory',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  }).formatToParts(instant);
  const field = (type) => Number(parts.find((p) => p.type === type).value);

  const anchorYear = field('year');
  const anchorMonth = field('month'); // 1-12
  const anchorDay = field('day');

  // Pure calendar arithmetic from the original anchor, so no clock, offset or
  // DST change can move a date, and a clamped month never shifts later ones.
  const dates = [];
  for (let i = 1; i <= count; i++) {
    const monthsFromSignup = anchorMonth - 1 + i;
    const year = anchorYear + Math.floor(monthsFromSignup / 12);
    const month = (monthsFromSignup % 12) + 1;
    const day = Math.min(anchorDay, daysInMonth(year, month));
    dates.push(
      `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
    );
  }
  return dates;
}

function daysInMonth(year, month) {
  if (month === 2) return isLeapYear(year) ? 29 : 28;
  return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
}

function isLeapYear(year) {
  return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}

What was wrong

Three separate bugs, two of which match your tickets exactly.

1. It never used timeZone at all. The parameter was accepted and ignored. The anchor day came from d.getUTCDate(), the UTC day number, and the output came from d.toISOString().slice(0, 10), a UTC date string. For your Auckland customer who signed up on the morning of the 1st, the UTC instant is still the 31st of the previous month (NZ is 12–13 hours ahead), so the anchor day was 31 and every renewal was emitted a day early. Anyone west of UTC would have had the opposite error. The fix reads the signup's local calendar date through Intl.DateTimeFormat with the customer's zone, and builds the output string from local year/month/day instead of re-serialising a UTC timestamp.

2. The anchor day ratcheted downwards. d was mutated in place and const day = d.getUTCDate() re-read it at the top of each iteration, so once a short month clamped the date the clamped value became the new anchor. A 31st signup gave 28 Feb, then 28 Mar, 28 Apr, and so on forever, which is precisely your January 31st report. The rule says always count from the original signup day, so the anchor is now captured once, before the loop, and each renewal is computed independently as "anchor month + i".

3. Date arithmetic on a Date object was the wrong tool for calendar dates. Even with the first two bugs fixed, mutating a Date ties month arithmetic to absolute instants, which invites off-by-one-day errors around DST transitions and offset changes. Renewal dates are plain calendar dates, so the loop now does integer arithmetic on year/month/day and only touches Date for parsing the input instant. There is also no longer any dependence on Date.UTC, which silently remaps years 0–99 into the 1900s.

Two sanity checks: renewalDates("2025-01-31T23:30:00Z", "Pacific/Auckland", 4) resolves to a local signup date of 2025-02-01 and returns ["2025-03-01", "2025-04-01", "2025-05-01", "2025-06-01"]; renewalDates("2025-01-31T12:00:00Z", "UTC", 4) returns ["2025-02-28", "2025-03-31", "2025-04-30", "2025-05-31"].

One thing to decide before you ship: this fixes the dates going forward, but existing subscriptions have already been billed on the drifted schedule, so you will probably want a one-off migration to re-anchor their stored renewal day to the original local signup day, plus a decision on whether to credit the customers who were billed a day early.

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top3/3, 100% passed
7 of 7 checks passedfloor 2/2, middle 2/2, top 3/3
  • Passed.
    Mid-month signups in UTC get the right dates
    Floor Tested
  • Passed.
    Returns exactly count dates as YYYY-MM-DD, in order
    Floor Tested
  • Passed.
    Uses the customer's local date (Auckland, Kolkata, LA, London)
    Middle Tested
  • Passed.
    Explains both causes: the UTC date and drifting month to month
    Middle Read
  • Passed.
    Signups on the 29th to 31st keep their day after short months
    Top Tested
  • Passed.
    Leap and non-leap Februaries are handled, including 2100
    Top Tested
  • Passed.
    Signups just before or after a clock change get the right local date
    Top Tested
  • Took 58 s.
  • First words after 37 s.
  • Wrote 4,983 tokens, 2,997 of them thinking.
  • 13¢ at API prices.
All numbers.
Time to finish58 s
Time to first words37 s
Input tokens2
Cached tokens1,375
Output tokens4,983
Thinking tokens2,997
Words in the answer783
Price at API rates13¢
F04Review a pull request before mergingLow 7/7Max 7/7Same

What it asks for. Pastes a pull request of about 170 lines that adds invoice endpoints to an Express and TypeScript API, and asks for a review before merging that lists what needs fixing in order of importance plus other points worth noting, in under 400 words.

Both settings passed 7 of 7 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

7 of 7

Review

Must fix before merge

1. GET /invoices/:id leaks other accounts' invoices (IDOR). findInvoiceById filters only on id, and the route never checks ownership. Any logged-in user who guesses or obtains an invoice id can read another account's invoice. Fix in the repo so it can't be forgotten:

export async function findInvoiceById(id: string, accountId: string) {
  const { rows } = await db.query(
    `SELECT ${COLUMNS} FROM invoices WHERE id = $1 AND account_id = $2`,
    [id, accountId],
  );
  return rows[0] ?? null;
}

and pass req.user.accountId from the route. Keep returning 404 (not 403) so ids aren't enumerable. Worth a test that asserts a cross-account id gives 404.

2. to is documented as inclusive but implemented as exclusive. The docs and the route comment say "last day to include (inclusive)", while the query uses created_at < $3. Invoices on the to day are silently dropped — a date-range bug users will report as missing data. Either add a day to to before querying (AND created_at < $3::date + 1, keeping the inclusive contract) or change docs and comment to exclusive. The inclusive version is friendlier and matches what you've already published.

3. No validation that from <= to. Currently returns an empty list, which looks like "no invoices" rather than a bad request. Return 400 bad_range.

Worth knowing

  • Route ordering: /:id is registered before /, which is fine here, but /:id will match any single segment. If you later add /invoices/export, it'll be swallowed by /:id. Put literal routes first as a habit.
  • Timezone: created_at is compared against bare YYYY-MM-DD strings, so the range boundaries resolve in whatever timezone the DB session uses. If created_at is timestamptz, decide explicitly (UTC day? account's timezone?) and cast in SQL, otherwise edge-of-day invoices land in the wrong bucket.
  • Sort whitelist is sound. Object.hasOwn plus the fixed ASC/DESC mapping means no SQL text comes from the request. Good. Consider rejecting unknown sort/dir values with a 400 instead of silently defaulting — silent fallback hides client bugs.
  • No pagination. An account with years of invoices will return everything in one response. Add a limit/cursor before this gets real data.
  • Unstable ordering: sorting by total_cents alone gives non-deterministic order for ties. Add , id as a tiebreaker.
  • fmtMoney → formatMoney rename is clean and all call sites are updated. Confirm nothing outside this diff (tests, other routers) still references the old name.
  • Missing error handling: if db.query rejects, there's no catch; verify you have an async-aware error handler, or wrap the handlers.
  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top3/3, 100% passed
7 of 7 checks passedfloor 2/2, middle 2/2, top 3/3
  • Passed.
    Gives a clear verdict: do not merge as it is
    Floor Counted
  • Passed.
    Stays under the 400-word limit
    Floor Counted
  • Passed.
    Spots that any signed-in user can read another account's invoice
    Middle Counted
  • Passed.
    Gives a concrete fix: scope the lookup by account or return 404/403
    Middle Counted
  • Passed.
    Spots that the end date is left out although the docs say inclusive
    Top Counted
  • Passed.
    Puts the invoice access hole first, ahead of the other issues
    Top Read
  • Passed.
    Does not call the ORDER BY string building an injection risk
    Top Read
  • Took 15 s.
  • First words after 2.0 s.
  • Wrote 1,041 tokens.
  • 4.6¢ at API prices.
All numbers.
Time to finish15 s
Time to first words2.0 s
Input tokens2
Cached tokens3,184
Output tokens1,041
Thinking tokens0
Words in the answer428
Price at API rates4.6¢

AI answer from Opus 5 · max effort · 1 Oct 2026

7 of 7

Must fix before merge

  1. GET /invoices/:id leaks other accounts' invoices. findInvoiceById filters only on id, so any signed-in user can read any invoice whose ID they obtain or guess. The list endpoint scopes by account_id; this one doesn't. Thread req.user.accountId through and add AND account_id = $2, returning 404 (not 403) on a miss. This is the blocker.
  2. to is documented inclusive but implemented exclusive. The SQL is created_at < $3 with $3 = '2026-01-31', so you silently drop everything on the last day of the range. Either keep the half-open comparison and add a day server-side, or change the docs. Also check created_at's type: if it's timestamptz, a bare date string resolves in the session timezone, so day boundaries shift — pin the zone explicitly.
  3. Malformed input becomes a 500, not a 400. DAY accepts 2026-02-31 and 2026-13-99, and /:id accepts any string. Postgres throws on an invalid date literal or a non-UUID id, and since both handlers are async with no try/catch, that rejection only turns into a response if you're on Express 5 or have an async wrapper — I don't see one, and receipts.ts has the same shape. Confirm the Express major version; on 4 these requests hang. Then validate dates properly and reject from > to.

Worth knowing

  • The dynamic ORDER BY is genuinely safe: whitelisted key, Object.hasOwn guard, hardcoded direction. The comment is accurate, so pre-empt the reviewer who flags it.
  • Unknown sort/dir values fall back silently, so a client typo returns mis-ordered data with a 200. A 400 is kinder.
  • No LIMIT or pagination: an account with years of invoices in range gets everything in one payload. Much easier to add before clients depend on the response shape.
  • Add a tiebreaker (ORDER BY ${column} ${direction}, id DESC). Ties on total_cents or same-second timestamps order non-deterministically, which will bite once paging exists.
  • No migration or index in the diff — confirm invoices exists and has an index on (account_id, created_at).
  • The fmtMoney → formatMoney rename is unrelated to the feature. Check nothing outside receipts.ts imports the old name; a full tsc run over the project will tell you.
  • No tests for either endpoint. At minimum, add one asserting a cross-account fetch returns 404.
  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top3/3, 100% passed
7 of 7 checks passedfloor 2/2, middle 2/2, top 3/3
  • Passed.
    Gives a clear verdict: do not merge as it is
    Floor Counted
  • Passed.
    Stays under the 400-word limit
    Floor Counted
  • Passed.
    Spots that any signed-in user can read another account's invoice
    Middle Counted
  • Passed.
    Gives a concrete fix: scope the lookup by account or return 404/403
    Middle Counted
  • Passed.
    Spots that the end date is left out although the docs say inclusive
    Top Counted
  • Passed.
    Puts the invoice access hole first, ahead of the other issues
    Top Read
  • Passed.
    Does not call the ORDER BY string building an injection risk
    Top Read
  • Took 46 s.
  • First words after 39 s.
  • Wrote 3,698 tokens, 2,811 of them thinking.
  • 11¢ at API prices.
All numbers.
Time to finish46 s
Time to first words39 s
Input tokens2
Cached tokens3,184
Output tokens3,698
Thinking tokens2,811
Words in the answer379
Price at API rates11¢
F05SQL: which customers left and came backLow 7/7Max 7/7Same

What it asks for. Gives the schema of a subscriptions database and asks for one SQLite query that lists each gap in a customer's access that ended with them returning, with the last day of access, the day it came back and the number of days without it. Plan changes can make periods touch, overlap or sit inside one another, and none of that counts as a gap. A small example with its expected output is included.

Both settings passed 7 of 7 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

7 of 7

WITH p AS (
  SELECT customer_id,
         start_date,
         COALESCE(end_date, '9999-12-31') AS end_date
  FROM subscription_periods
),
m AS (
  SELECT customer_id,
         start_date,
         MAX(end_date) OVER (
           PARTITION BY customer_id
           ORDER BY start_date, end_date
           ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
         ) AS prev_end
  FROM p
)
SELECT c.name                                        AS name,
       m.prev_end                                    AS last_access,
       m.start_date                                  AS came_back,
       CAST(julianday(m.start_date) - julianday(m.prev_end) AS INTEGER) - 1
                                                     AS days_without
FROM m
JOIN customers c ON c.id = m.customer_id
WHERE m.prev_end IS NOT NULL
  AND m.start_date > date(m.prev_end, '+1 day')
ORDER BY c.name, came_back, c.id;

How it works:

  • p normalises an open-ended period (end_date IS NULL) to the sentinel '9999-12-31', so an active period counts as covering everything from its start onwards.
  • m sorts each customer's periods by start_date (then end_date) and computes a running maximum of all earlier periods' end dates (ROWS ... 1 PRECEDING). That high-water mark is the last day of access achieved so far, which is what makes touching, same-day, overlapping and fully-nested periods harmless — they never push the mark backwards.
  • A gap exists only when the current period starts strictly later than the day after the high-water mark, i.e. at least one whole day with no access. prev_end IS NULL skips each customer's first period.
  • days_without is the plain day difference minus one, so 06-30 → 07-10 gives 9. Dates are compared as 'YYYY-MM-DD' strings, where lexical order equals chronological order, so ids are never used for ordering.
  • A customer who left for good produces no row (there is no later period), and nothing can start after a 9999-12-31 mark, so an active latest period never yields a gap.
  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top3/3, 100% passed
7 of 7 checks passedfloor 2/2, middle 2/2, top 3/3
  • Passed.
    The query runs and returns the four named columns
    Floor Tested
  • Passed.
    It gets the example in the question right
    Floor Tested
  • Passed.
    Periods that touch or overlap are not counted as gaps
    Middle Tested
  • Passed.
    Several gaps per customer, same-name customers, sort order
    Middle Tested
  • Passed.
    A period inside a longer one does not hide or invent a gap
    Top Tested
  • Passed.
    Open-ended (NULL end) periods never produce a gap after them
    Top Tested
  • Passed.
    Days without access is exact across month, leap and year ends
    Top Tested
  • Took 17 s.
  • First words after 9.7 s.
  • Wrote 1,466 tokens, 705 of them thinking.
  • 4.7¢ at API prices.
All numbers.
Time to finish17 s
Time to first words9.7 s
Input tokens2
Cached tokens1,675
Output tokens1,466
Thinking tokens705
Words in the answer274
Price at API rates4.7¢

AI answer from Opus 5 · max effort · 1 Oct 2026

7 of 7

WITH p AS (
    SELECT
        customer_id,
        start_date,
        COALESCE(end_date, '9999-12-31') AS end_date   -- NULL = still active = covers forever
    FROM subscription_periods
),
runs AS (
    SELECT
        customer_id,
        start_date,
        MAX(end_date) OVER (
            PARTITION BY customer_id
            ORDER BY start_date, end_date
            ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
        ) AS prev_end          -- furthest day covered by everything that started earlier
    FROM p
)
SELECT
    c.name                                                        AS name,
    r.prev_end                                                    AS last_access,
    r.start_date                                                  AS came_back,
    CAST(julianday(r.start_date) - julianday(r.prev_end) AS INTEGER) - 1
                                                                  AS days_without
FROM runs AS r
JOIN customers AS c ON c.id = r.customer_id
WHERE r.prev_end IS NOT NULL
  AND julianday(r.start_date) > julianday(r.prev_end) + 1
ORDER BY name, came_back, r.customer_id;

How it works

  • p normalises the data: an open-ended period (end_date IS NULL) becomes '9999-12-31', so an active subscription swallows anything that follows it and can never be followed by a gap.
  • runs walks each customer's periods in date order and, for every period, computes prev_end = the running maximum end date of all periods that started before it. Using MAX rather than "the previous row's end" is what makes nested periods harmless: a short period sitting inside a longer one can't pull the coverage frontier backwards. Dates are YYYY-MM-DD, so plain text comparison is already chronological, and the ROWS ... 1 PRECEDING frame excludes the row itself.
  • A gap exists only when a period starts more than one day after the frontier, i.e. start_date > prev_end + 1 day. Touching (start = prev_end + 1), same-day starts and overlaps all fail that test, exactly as you wanted. The first period of each customer has prev_end IS NULL and is skipped.
  • days_without is the whole days strictly between the two dates: julianday difference minus 1 (2024-06-30 → 2024-07-10 gives 10 − 1 = 9).
  • A customer who left and never returned produces no row, because a gap is only ever reported by the period that resumes access.
  • If several periods start on the same day right after a gap, only the first of them reports it, so you get one row per gap, not duplicates.

Two small notes: ordering by name uses SQLite's default binary collation (uppercase before lowercase) — add COLLATE NOCASE in the ORDER BY if you want case-insensitive alphabetical. And the '9999-12-31' sentinel assumes no real end date is that value, which is safe unless you store far-future dates deliberately.

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top3/3, 100% passed
7 of 7 checks passedfloor 2/2, middle 2/2, top 3/3
  • Passed.
    The query runs and returns the four named columns
    Floor Tested
  • Passed.
    It gets the example in the question right
    Floor Tested
  • Passed.
    Periods that touch or overlap are not counted as gaps
    Middle Tested
  • Passed.
    Several gaps per customer, same-name customers, sort order
    Middle Tested
  • Passed.
    A period inside a longer one does not hide or invent a gap
    Top Tested
  • Passed.
    Open-ended (NULL end) periods never produce a gap after them
    Top Tested
  • Passed.
    Days without access is exact across month, leap and year ends
    Top Tested
  • Took 52 s.
  • First words after 41 s.
  • Wrote 4,196 tokens, 3,155 of them thinking.
  • 12¢ at API prices.
All numbers.
Time to finish52 s
Time to first words41 s
Input tokens2
Cached tokens1,675
Output tokens4,196
Thinking tokens3,155
Words in the answer398
Price at API rates12¢
F06A contacts import that survives real CSV filesLow 9/9Max 9/9Same

What it asks for. Asks for a Python function, standard library only, that imports contacts from CSV files exported by many different tools and returns each contact's name, email and phone. The files vary in header names, comma or semicolon separators, a byte order mark, quoted fields with line breaks, Windows line endings and older files in Windows-1252. Rows without an email are skipped, and a repeated email keeps its first row.

Both settings passed 9 of 9 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

9 of 9

import csv
import io

NAME_KEYS = {"name", "full name", "contact name"}
EMAIL_KEYS = {"email", "e-mail", "email address", "e-mail address"}
PHONE_KEYS = {"phone", "phone number", "mobile", "mobile phone", "telephone"}


def _decode(raw):
    if raw.startswith(b"\xef\xbb\xbf"):
        raw = raw[3:]
    try:
        return raw.decode("utf-8")
    except UnicodeDecodeError:
        return raw.decode("cp1252")


def _header_line(text):
    """First physical line, respecting quotes (a quoted field may span lines)."""
    out = []
    in_quotes = False
    i = 0
    while i < len(text):
        ch = text[i]
        if ch == '"':
            if in_quotes and i + 1 < len(text) and text[i + 1] == '"':
                out.append('""')
                i += 2
                continue
            in_quotes = not in_quotes
        elif ch == "\n" and not in_quotes:
            break
        out.append(ch)
        i += 1
    return "".join(out)


def _delimiter(header):
    counts = {}
    in_quotes = False
    for i, ch in enumerate(header):
        if ch == '"':
            in_quotes = not in_quotes
        elif not in_quotes and ch in ";,":
            counts[ch] = counts.get(ch, 0) + 1
    if counts.get(";", 0) > counts.get(",", 0):
        return ";"
    return ","


def import_contacts(path):
    with open(path, "rb") as fh:
        text = _decode(fh.read())
    if not text.strip():
        return []

    # Normalise line endings so line breaks inside quoted fields become "\n".
    text = text.replace("\r\n", "\n").replace("\r", "\n")

    delim = _delimiter(_header_line(text))
    reader = csv.reader(io.StringIO(text, newline=""), delimiter=delim)

    try:
        header = next(reader)
    except StopIteration:
        return []

    idx = {}
    for pos, cell in enumerate(header):
        key = cell.strip().strip('\ufeff').lower()
        if key in NAME_KEYS:
            idx.setdefault("name", pos)
        elif key in EMAIL_KEYS:
            idx.setdefault("email", pos)
        elif key in PHONE_KEYS:
            idx.setdefault("phone", pos)

    if "email" not in idx:
        return []

    results = []
    seen = set()
    for row in reader:
        def get(field):
            pos = idx.get(field)
            if pos is None or pos >= len(row):
                return ""
            return row[pos].strip()

        email = get("email").lower()
        if not email or email in seen:
            continue
        seen.add(email)
        results.append({"name": get("name"), "email": email, "phone": get("phone")})
    return results

How it works: the file is read as bytes so the UTF-8 BOM can be stripped and the text decoded as UTF-8 with a fallback to Windows-1252; line endings are normalised to \n before parsing, which is what makes a line break inside a quoted field come out as a single \n. The delimiter is chosen by counting unquoted commas versus semicolons in the header line only, then csv.reader does the quoting work and the header is mapped to column positions via the accepted-name sets (case- and space-insensitive). Each row is read by position, with missing cells treated as empty and extra cells ignored; rows with no email (which covers blank and separator-only lines) are skipped, emails are lower-cased and de-duplicated keeping the first occurrence, and the original order is preserved.

  • Floor2/2, 100% passed
  • Middle3/3, 100% passed
  • Top4/4, 100% passed
9 of 9 checks passedfloor 2/2, middle 3/3, top 4/4
  • Passed.
    A plain comma-separated file is imported
    Floor Tested
  • Passed.
    Fields are stripped, emails lower-cased, rows without an email skipped
    Floor Tested
  • Passed.
    Header names vary in case, spacing, wording and order
    Middle Tested
  • Passed.
    A UTF-8 BOM and Windows line endings are handled
    Middle Tested
  • Passed.
    Quoted commas and doubled quotes are read correctly
    Middle Tested
  • Passed.
    Line breaks inside quoted fields are kept as one \n
    Top Tested
  • Passed.
    Semicolon files are read, even with commas in fields
    Top Tested
  • Passed.
    Windows-1252 files decode correctly and UTF-8 stays intact
    Top Tested
  • Passed.
    Duplicates, blank and short rows, and a file with every quirk at once
    Top Tested
  • Took 17 s.
  • First words after 5.9 s.
  • Wrote 1,531 tokens, 266 of them thinking.
  • 4.7¢ at API prices.
All numbers.
Time to finish17 s
Time to first words5.9 s
Input tokens2
Cached tokens1,387
Output tokens1,531
Thinking tokens266
Words in the answer420
Price at API rates4.7¢

AI answer from Opus 5 · max effort · 1 Oct 2026

9 of 9

import csv
import io
import re

_ALIASES = {
    "name":  {"name", "full name", "contact name"},
    "email": {"email", "e-mail", "email address", "e-mail address"},
    "phone": {"phone", "phone number", "mobile", "mobile phone", "telephone"},
}


def _read_text(path):
    """Decode the file as UTF-8 (BOM-aware), falling back to Windows-1252."""
    with open(path, "rb") as fh:
        raw = fh.read()
    try:
        text = raw.decode("utf-8-sig")
    except UnicodeDecodeError:
        text = raw.lstrip(b"\xef\xbb\xbf").decode("cp1252", errors="replace")
    # Normalise all line endings, including ones inside quoted fields, to "\n".
    return text.replace("\r\n", "\n").replace("\r", "\n")


def _sniff_delimiter(text):
    """Count commas vs semicolons outside quotes on the first non-blank line."""
    counts = {",": 0, ";": 0}
    has_content = False
    in_quotes = False
    i, n = 0, len(text)
    while i < n:
        ch = text[i]
        if ch == '"':
            has_content = True
            if in_quotes and i + 1 < n and text[i + 1] == '"':
                i += 1                      # doubled quote inside a quoted field
            else:
                in_quotes = not in_quotes
        elif in_quotes:
            pass
        elif ch == "\n":
            if has_content:
                break                       # end of the header line
            counts = {",": 0, ";": 0}       # leading blank line: start over
        elif ch in counts:
            counts[ch] += 1
        elif not ch.isspace():
            has_content = True
        i += 1
    return ";" if counts[";"] > counts[","] else ","


def _norm_header(cell):
    return re.sub(r"\s+", " ", cell.replace("\ufeff", "").strip()).lower()


def _cell(row, pos):
    if pos is None or pos >= len(row):
        return ""
    return row[pos].strip()


def import_contacts(path):
    """Read a contacts CSV and return [{'name': ..., 'email': ..., 'phone': ...}, ...]."""
    text = _read_text(path)
    if not text.strip():
        return []

    reader = csv.reader(io.StringIO(text, newline=""),
                        delimiter=_sniff_delimiter(text))

    header = None
    for row in reader:
        if any(cell.strip() for cell in row):   # skip leading blank/junk lines
            header = row
            break
    if header is None:
        return []

    index = {}
    for pos, cell in enumerate(header):
        key = _norm_header(cell)
        for field, aliases in _ALIASES.items():
            if key in aliases and field not in index:
                index[field] = pos
    if "email" not in index:                    # nothing usable in this file
        return []

    contacts, seen = [], set()
    for row in reader:
        email = _cell(row, index.get("email")).lower()
        if not email or email in seen:          # no email, or a duplicate
            continue
        seen.add(email)
        contacts.append({
            "name":  _cell(row, index.get("name")),
            "email": email,
            "phone": _cell(row, index.get("phone")),
        })
    return contacts

How it works: the file is read as bytes and decoded with utf-8-sig (which also eats a BOM), falling back to cp1252 if that fails, and all line endings are collapsed to \n up front — that's what makes a line break inside a quoted field come back as a single \n. The delimiter is chosen by counting commas and semicolons outside quotes on the first non-blank line, then the real parsing is left to csv.reader, so quoting, doubled quotes and embedded separators/newlines are handled for you.

The header is normalised (BOM stripped, trimmed, whitespace collapsed, lower-cased) and matched against the alias sets to get a column index per field; missing name/phone columns and short rows both just yield "" via _cell, and extra cells are never looked at. Rows are emitted in file order, with every field stripped, the email lower-cased, and rows skipped when the email is empty or already seen — so a header-only or empty file naturally gives [].

  • Floor2/2, 100% passed
  • Middle3/3, 100% passed
  • Top4/4, 100% passed
9 of 9 checks passedfloor 2/2, middle 3/3, top 4/4
  • Passed.
    A plain comma-separated file is imported
    Floor Tested
  • Passed.
    Fields are stripped, emails lower-cased, rows without an email skipped
    Floor Tested
  • Passed.
    Header names vary in case, spacing, wording and order
    Middle Tested
  • Passed.
    A UTF-8 BOM and Windows line endings are handled
    Middle Tested
  • Passed.
    Quoted commas and doubled quotes are read correctly
    Middle Tested
  • Passed.
    Line breaks inside quoted fields are kept as one \n
    Top Tested
  • Passed.
    Semicolon files are read, even with commas in fields
    Top Tested
  • Passed.
    Windows-1252 files decode correctly and UTF-8 stays intact
    Top Tested
  • Passed.
    Duplicates, blank and short rows, and a file with every quirk at once
    Top Tested
  • Took 1 min 4 s.
  • First words after 52 s.
  • Wrote 5,706 tokens, 4,230 of them thinking.
  • 15¢ at API prices.
All numbers.
Time to finish1 min 4 s
Time to first words52 s
Input tokens2
Cached tokens1,387
Output tokens5,706
Thinking tokens4,230
Words in the answer527
Price at API rates15¢
F07Make a slow script fastLow 7/7Max 7/7Same

What it asks for. Pastes a Python function that matches bank payments to open invoices by customer, amount and a due date within 7 days, and says it is far too slow on about 200,000 of each. It asks for a replacement that runs in a couple of seconds, returns exactly the same results for every input and leaves the lists it is given unchanged.

Both settings passed 7 of 7 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

7 of 7

from bisect import bisect_left
from datetime import timedelta


def match_payments(payments, invoices):
    """Match bank payments to open invoices.

    payments: list of dicts with id, customer, amount (integer cents) and date (datetime.date).
    invoices: list of dicts with id, customer, amount (integer cents) and due (datetime.date).

    Payments are handled one at a time, in the order they are given. A payment is
    matched to the unpaid invoice that has the same customer and the same amount,
    whose due date is within 7 days either side of the payment date (7 days
    apart still counts). If several invoices qualify, the one with the earliest
    due date wins; if they are due on the same day, the lowest invoice id wins.
    An invoice can be matched to one payment only.

    Returns (matches, unmatched):
      matches   - list of (payment_id, invoice_id), in payment order
      unmatched - list of ids of the payments that found no invoice, in payment order
    """
    week = timedelta(days=7)

    # Bucket invoices by (customer, amount); inside a bucket, sort by (due, id)
    # so the first still-unused invoice inside the date window is always the winner.
    groups = {}
    for inv in invoices:
        groups.setdefault((inv["customer"], inv["amount"]), []).append(inv)

    buckets = {}
    for key, items in groups.items():
        items = sorted(items, key=lambda inv: (inv["due"], inv["id"]))
        n = len(items)
        # nxt is a disjoint-set "skip list": nxt[i] eventually points at the first
        # index >= i that has not been consumed yet (n means "none left").
        buckets[key] = (items, [inv["due"] for inv in items], list(range(n + 1)), n)

    def find(nxt, i):
        root = i
        while nxt[root] != root:
            root = nxt[root]
        while nxt[i] != root:          # path compression
            nxt[i], i = root, nxt[i]
        return root

    used = set()
    matches = []
    unmatched = []

    for pay in payments:
        bucket = buckets.get((pay["customer"], pay["amount"]))
        best = None
        if bucket is not None:
            items, dues, nxt, n = bucket
            date = pay["date"]
            hi = date + week
            i = find(nxt, bisect_left(dues, date - week))
            while i < n and dues[i] <= hi:
                inv = items[i]
                if inv["id"] in used:
                    # Same id already consumed elsewhere: retire this slot too.
                    nxt[i] = i + 1
                    i = find(nxt, i + 1)
                    continue
                best = inv
                nxt[i] = i + 1          # consume this slot
                break

        if best is None:
            unmatched.append(pay["id"])
        else:
            used.add(best["id"])
            matches.append((pay["id"], best["id"]))

    return matches, unmatched

What was slow: the old version scanned all 200,000 invoices for every one of 200,000 payments — 4·10¹⁰ dict lookups and comparisons. The work was quadratic, and nearly all of it was spent rejecting invoices with the wrong customer or amount.

What changed:

  • Invoices are bucketed once by (customer, amount), so a payment only ever looks at invoices that could possibly match.
  • Each bucket is sorted by (due, id) — exactly the old tie-break order — so the winner is simply the first eligible entry, not a full minimum scan. A binary search jumps straight to the first invoice due on or after date - 7 days, and the scan stops once due > date + 7 days.
  • The "an invoice can only be used once" problem is the remaining trap: with a thousand invoices at the same amount, re-scanning already-matched ones would still be quadratic. A union-find "next unused slot" array makes skipping consumed invoices amortised near-constant, so a big customer costs no more than a small one.

Behaviour is identical: same winner rule (earliest due, then lowest id), same inclusive 7-day window using date arithmetic, same payment-order output, and a payment with no bucket at all falls through to unmatched as before. The duplicate-id case is kept too: the original skipped any invoice whose id was already in used, so the scan here retires such slots rather than matching them. Neither input list nor any dict is mutated — buckets hold references to the same dicts, and the sort builds a new list — so callers can reuse both lists afterwards. Expect a couple of seconds at your data size. If invoice ids are not comparable with < (e.g. mixed int and str), that would need to be known, since the sort relies on the same comparison the old code did.

  • Floor2/2, 100% passed
  • Middle3/3, 100% passed
  • Top2/2, 100% passed
7 of 7 checks passedfloor 2/2, middle 3/3, top 2/2
  • Passed.
    Same results as the original on simple cases
    Floor Tested
  • Passed.
    Same results as the original on many random small inputs
    Floor Tested
  • Passed.
    The 7-day limit is identical at both edges
    Middle Tested
  • Passed.
    The two input lists are left unchanged
    Middle Tested
  • Passed.
    Fast on 20,000 payments and invoices, same results
    Middle Tested
  • Passed.
    Earliest due date, then lowest id, in payment order, each invoice once
    Top Tested
  • Passed.
    Under 6 seconds on 200,000 payments and invoices, same results
    Top Tested
  • Took 32 s.
  • First words after 16 s.
  • Wrote 2,548 tokens, 954 of them thinking.
  • 7.3¢ at API prices.
All numbers.
Time to finish32 s
Time to first words16 s
Input tokens2
Cached tokens1,554
Output tokens2,548
Thinking tokens954
Words in the answer693
Price at API rates7.3¢

AI answer from Opus 5 · max effort · 1 Oct 2026

7 of 7

Replacement

from bisect import bisect_left
from datetime import timedelta


def match_payments(payments, invoices):
    """Match bank payments to open invoices.

    payments: list of dicts with id, customer, amount (integer cents) and date (datetime.date).
    invoices: list of dicts with id, customer, amount (integer cents) and due (datetime.date).

    Payments are handled one at a time, in the order they are given. A payment is
    matched to the unpaid invoice that has the same customer and the same amount,
    whose due date is within 7 days either side of the payment date (7 days
    apart still counts). If several invoices qualify, the one with the earliest
    due date wins; if they are due on the same day, the lowest invoice id wins.
    An invoice can be matched to one payment only.

    Returns (matches, unmatched):
      matches   - list of (payment_id, invoice_id), in payment order
      unmatched - list of ids of the payments that found no invoice, in payment order

    Neither input list, nor any dict inside them, is read-modified or reordered.
    """
    window = timedelta(days=7)

    # 1. Bucket invoices by (customer, amount). Customer and amount must match
    #    exactly, so a payment can only ever match something in its own bucket.
    grouped = {}
    for inv in invoices:
        key = (inv["customer"], inv["amount"])
        row = (inv["due"], inv["id"])
        if key in grouped:
            grouped[key].append(row)
        else:
            grouped[key] = [row]

    # 2. Sort each bucket by (due, id) -- exactly the old tie-break order -- and
    #    split it into parallel lists so the due dates can be binary-searched.
    #    'nxt' is a union-find style "next slot that may still be open" pointer;
    #    the extra slot n is a sentinel meaning "nothing left in this bucket".
    buckets = {}
    for key, rows in grouped.items():
        rows.sort()
        buckets[key] = (
            [r[0] for r in rows],            # due dates, ascending
            [r[1] for r in rows],            # invoice ids, same order
            list(range(len(rows) + 1)),      # nxt
        )

    used = set()
    matches = []
    unmatched = []
    add_match = matches.append
    add_unmatched = unmatched.append

    for pay in payments:
        bucket = buckets.get((pay["customer"], pay["amount"]))
        if bucket is None:
            add_unmatched(pay["id"])
            continue

        dues, ids, nxt = bucket
        n = len(ids)
        pay_date = pay["date"]

        # First invoice that is not too early. Anything before this is outside
        # this payment's window (but may still match a later payment).
        i = bisect_left(dues, pay_date - window)

        # Follow the nxt chain to the first slot that is still open, flattening
        # the chain on the way (path compression), so each closed slot is only
        # ever stepped over once overall.
        while True:
            root = i
            while nxt[root] != root:
                root = nxt[root]
            while nxt[i] != root:
                nxt[i], i = root, nxt[i]
            i = root
            if i >= n or ids[i] not in used:
                break
            nxt[i] = i + 1          # this id was consumed elsewhere; retire slot
            i += 1

        if i >= n or dues[i] - pay_date > window:
            add_unmatched(pay["id"])
        else:
            inv_id = ids[i]
            used.add(inv_id)
            nxt[i] = i + 1          # invoice now taken; never offer it again
            add_match((pay["id"], inv_id))

    return matches, unmatched

What was slow, and what changed

The old cost. Every payment walked the whole invoice list: 200,000 × 200,000 ≈ 4×10¹⁰ iterations, each doing several dict lookups. Ten minutes wasn't anywhere near enough. Two things made it worse: invoices that had already been matched were still visited and re-tested by every later payment, and the "best so far" comparison kept scanning even after the winner had been seen.

The three changes:

  1. Index instead of scan. Customer and amount have to match exactly, so invoices are bucketed once by (customer, amount). A payment now only looks at its own bucket.
  2. Sort once, binary-search. Each bucket is sorted by (due, id) — precisely the old tie-break rule (earliest due, then lowest id). The winner is therefore the first still-open invoice at or after due >= date - 7 days; a bisect jumps straight to that position, and if that invoice's due date is past date + 7 days there is no match at all, because every later one is due even later.
  3. Retire matched invoices in O(1). This is what matters for your big customer with a thousand-plus invoices at the same amount: without it, each payment would walk over the already-matched run again. The nxt array is a union-find "next open slot" pointer with path compression, so a long run of consumed invoices is skipped in amortised constant time.

Overall it's O((P + I) log I) — roughly a second on 200k/200k, dominated by the sorts and the per-payment bisect.

Why the output is identical. Same payment order, same candidate test (same customer, same amount, |due - date| <= 7 days inclusive), same tie-break, and one payment per invoice. The global used set is kept deliberately: if two invoice dicts ever share an id, the old code treated them as one unit (once either was taken, both were skipped, including across customers), and the lazy "retire slot" step reproduces that exactly.

Inputs are untouched. Nothing is written back to the dicts and neither list is sorted in place; the buckets hold copies of (due, id), so callers can reuse both lists afterwards.

Two assumptions, both consistent with your docstring: customer and amount are hashable (strings/ints are fine), and the dates really are datetime.date, so due - date is a whole number of days. If they were ever datetime objects with times, the old code's abs(...).days truncation would make its boundary subtly different from a clean 7-day window — worth knowing, but not a concern for date values.

Worth running both versions over one real day's file and asserting the two returned tuples are equal before you swap it in.

  • Floor2/2, 100% passed
  • Middle3/3, 100% passed
  • Top2/2, 100% passed
7 of 7 checks passedfloor 2/2, middle 3/3, top 2/2
  • Passed.
    Same results as the original on simple cases
    Floor Tested
  • Passed.
    Same results as the original on many random small inputs
    Floor Tested
  • Passed.
    The 7-day limit is identical at both edges
    Middle Tested
  • Passed.
    The two input lists are left unchanged
    Middle Tested
  • Passed.
    Fast on 20,000 payments and invoices, same results
    Middle Tested
  • Passed.
    Earliest due date, then lowest id, in payment order, each invoice once
    Top Tested
  • Passed.
    Under 6 seconds on 200,000 payments and invoices, same results
    Top Tested
  • Took 2 min 34 s.
  • First words after 2 min 13 s.
  • Wrote 12,537 tokens, 10,392 of them thinking.
  • 32¢ at API prices.
All numbers.
Time to finish2 min 34 s
Time to first words2 min 13 s
Input tokens2
Cached tokens1,554
Output tokens12,537
Thinking tokens10,392
Words in the answer946
Price at API rates32¢

Planning and architecture

A01Plan a booking app for a dog groomerLow 8/9Max 8/9Same

What it asks for. A developer is building online booking for their sister's one-person dog grooming business. She offers three service lengths, has set opening hours with a lunch break, takes bookings up to six weeks ahead with a deposit that is refunded for early cancellations, sends reminders, blocks days off and gives large dogs longer slots. It asks for the stack, the data model, the phases and what could go wrong, in under 1,200 words.

Both settings passed 8 of 9 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

8 of 9

The one decision that shapes everything

Duration is a property of the booking, not the service. "Large dog = double" means your three services actually produce six possible durations: 30/60/90/120/180. Pick the dog size before showing slots, then compute duration = service.base_minutes * size_multiplier. If you treat large-dog as a modifier applied after slot selection, you will spend a weekend untangling double-booked afternoons.

Second: store everything in UTC, do all availability maths in her local timezone. Her working day is "09:00 local," which is a different UTC instant in summer and winter. Keep one timezone string in settings (e.g. Europe/London), and never hand-roll offsets.

Stack

Favour boring and hosted — you're working weekends and she can't debug anything.

  • Backend: Python, FastAPI or Django. Pick Django if you want the admin for free (useful for you, not for her). Pick FastAPI if you prefer writing your own small API.
  • DB: Postgres (managed — Supabase, Neon, Railway). Needs proper transactions and tstzrange/exclusion constraints.
  • Frontend: one small app. Next.js or plain Vite + React, mobile-first. Two surfaces: public booking flow, and her admin screens.
  • Payments: Stripe. Use a manual-capture PaymentIntent or a saved card rather than a literal 20% charge — see the deposit section.
  • Email/SMS: Resend or Postmark for email; Twilio if she wants SMS reminders. Email first.
  • Scheduled jobs: whatever your host gives you (Railway/Render cron, or a GitHub Action hitting a protected endpoint). Don't add Celery for one nightly job.
  • Auth: none for clients — booking by email + a magic link to manage/cancel. A single password or magic link for her admin.

Data model

Service            id, name, base_minutes, price_cents, active
DogSize            id, name, multiplier        # small/medium 1.0, large 2.0
Client             id, name, email, phone, notes
Dog                id, client_id, name, breed, size_id, notes
Booking            id, client_id, dog_id, service_id,
                   starts_at (timestamptz), ends_at (timestamptz),
                   duration_minutes, price_cents, deposit_cents,
                   status, payment_intent_id, cancelled_at,
                   reminder_sent_at, created_at
BusinessHours      weekday (1-6), opens, closes, break_start, break_end
Closure            id, starts_at, ends_at, reason   # holidays, sick days, one-offs
Settings           timezone, booking_horizon_days, min_notice_hours,
                   deposit_percent, cancellation_window_hours, slot_granularity

Notes:

  • ends_at is stored, not computed. It's derived at booking time from duration, then frozen. If she later changes a service from 60 to 75 minutes, existing bookings must not silently move.
  • duration_minutes and price_cents are copied onto the booking. Never join to live service rows for historical data.
  • status: pending_payment, confirmed, cancelled_by_client, cancelled_by_owner, completed, no_show.
  • Closure as a datetime range (not a date) handles both "away all next week" and "gone by 15:00 Thursday".
  • Add a DB-level guard against overlaps: EXCLUDE USING gist (tstzrange(starts_at, ends_at) WITH &&) WHERE (status IN ('pending_payment','confirmed')) This is your real protection. Application checks will lose a race eventually.

Availability algorithm

Don't store slots. Generate them:

  1. Client picks service + dog size → you know duration.
  2. For each date in [now + min_notice, now + 42 days]:
    • Look up BusinessHours for that weekday in her local timezone. No row → closed.
    • Build local intervals: [opens, break_start] and [break_end, closes].
    • Convert to UTC instants.
    • Subtract overlapping Closure ranges and existing non-cancelled bookings.
    • Walk remaining free intervals in slot_granularity steps (15 min), emitting any start where start + duration still fits inside that interval.
  3. Return grouped by day.

A 180-minute large-dog groom will only fit 09:00–12:30 (one slot: 09:00) or not at all that afternoon. That's correct behaviour, but tell her — she may want to allow grooms to run through lunch, in which case make the break a soft block she can override.

The deposit, concretely

A 20% deposit that's forfeited on late cancellation is a payment problem, not a schema problem.

Simplest robust version: charge the 20% immediately with Stripe. On cancellation, if starts_at - now > 24h, issue a refund via API; otherwise don't, and say so plainly in the confirmation email and on the cancel screen before they confirm. The remaining 80% is settled in person — don't try to take final payment online in v1.

Watch: refund fees are usually not returned to you, the 24-hour boundary must be evaluated server-side in UTC, and a pending_payment booking must expire (15 min hold) so abandoned checkouts don't block her diary. Use Stripe webhooks to confirm, not the browser redirect — people close tabs.

Phases

Phase 1 — the engine (no UI). Models, migrations, availability function, overlap constraint. Write tests first for: a 90-min booking at 11:30 (must be rejected, hits lunch), large-dog 180-min on a day with an existing 14:00 booking, a slot on the DST spring-forward and autumn-fall-back weekends, a closure that half-covers a day. Get this right and the rest is CRUD.

Phase 2 — booking flow, no payment. Service → size → date → slot → details → confirm. Email confirmation with a magic-link manage page. Now it's usable; she could run it on trust.

Phase 3 — Stripe deposit. PaymentIntent, webhook, pending expiry, cancel-with-refund logic.

Phase 4 — her admin. This is the phase you'll under-budget. On a phone she needs: today's list, this week's list, add a closure, add a manual booking (phone calls will still happen), cancel/reschedule, edit a dog's size. Big tap targets, no tables. Add to home screen as a PWA.

Phase 5 — reminders. Nightly cron: find confirmed bookings starting in the next 24–48h window with reminder_sent_at IS NULL, send, stamp the column. The stamp is what makes it idempotent and safe to re-run.

Phase 6 — polish. Buffer time between appointments, no-show tracking, recurring clients, calendar feed.

What could go wrong

  • DST. Not the arithmetic — the tests. Run your availability function across both switchover weekends and assert the day still has the right number of slots.
  • Dog size lies. Clients under-report to get a shorter slot. Let her correct the size and adjust the booking; expect it to happen.
  • Double booking via race. Covered by the exclusion constraint. Catch the violation and return a friendly "just taken, pick another."
  • Abandoned checkouts holding slots. Needs an expiry job from day one of Phase 3.
  • The 24-hour argument. Clients will cancel at 23 hours and complain. Show the deadline as an explicit date and time in the confirmation email, not as "24 hours."
  • Email in spam. Set up SPF/DKIM on her domain before launch, or reminders silently fail.
  • Back-to-back reality. Real grooming needs cleanup time. Ask her now whether she wants a 15-minute buffer — it's cheap in Phase 1, awkward later.
  • Scope creep from her. Loyalty cards, photos, vaccination records. Note them, ship Phase 2, let her use it for a month first.

Realistic effort: phases 1–3 are two or three solid weekends. Phase 4 is another one. Don't skip Phase 2's standalone usefulness — getting her real feedback before you wire up money will change what you build.

  • Floor2/2, 100% passed
  • Middle3/4, 75% passed
  • Top3/3, 100% passed
8 of 9 checks passedfloor 2/2, middle 3/4, top 3/3
  • Passed.
    Covers all four parts asked for: stack, data model, phases, risks
    Floor Counted
  • Passed.
    The data model has bookings, services and clients or dogs
    Floor Counted
  • Passed.
    Stays under the 1,200-word limit
    Middle Counted
  • Passed.
    Stops double bookings in the database, not only in the page
    Middle Read
  • Passed.
    Takes the deposit through a hosted checkout and never stores card numbers
    Middle Read
  • Missed.
    The first phase is small and usable before payments exist'Phase 1 — the engine (no UI)' is models, migrations and tests only; a working booking flow only arrives in Phase 2, so…
    Middle Read
  • Passed.
    Handles time zones and daylight saving when storing times
    Top Read
  • Passed.
    Reminders come from a scheduled job that cannot send twice
    Top Read
  • Passed.
    Applies the 24-hour refund rule from the appointment start time
    Top Read
  • Took 45 s.
  • First words after 3.0 s.
  • Wrote 2,883 tokens, 65 of them thinking.
  • 7.9¢ at API prices.
All numbers.
Time to finish45 s
Time to first words3.0 s
Input tokens2
Cached tokens1,038
Output tokens2,883
Thinking tokens65
Words in the answer1,147
Price at API rates7.9¢

AI answer from Opus 5 · max effort · 1 Oct 2026

8 of 9

Booking system for a one-chair grooming business

The decision that shapes everything

Duration isn't known until the client picks both a service and a dog size. So don't model "slots" as rows in a table. Model three things:

  1. Open intervals for a date (from weekly hours, minus lunch, minus time off).
  2. Busy intervals (existing bookings).
  3. A pure function: available_starts(date, duration_minutes) -> [times].

Write and test that function first, before any UI or payments. It's the only genuinely tricky code in the project, and it's where bugs become double-bookings.

Run the numbers now, because they'll surprise her: morning is 210 minutes (9:00–12:30), afternoon is 210 (13:30–17:00). A large dog on the 90-minute service is 180 minutes, so it only fits starting at 9:00, 9:30, 13:30 or 14:00 — and taking one kills most of that half-day. Ask her whether big-dog long grooms should be restricted to specific start times, or get a longer day, or just be rare. Also ask about buffer/cleanup time between dogs; if she wants 15 minutes, bake it into the busy interval, not the service duration.

Stack

Python backend — the availability and timezone logic is the hard part, and you're comfortable there.

  • Django + PostgreSQL. Django gives you auth, migrations, forms and an admin for free. Postgres gives you the overlap constraint below, which is worth more than any application-level locking you'll write.
  • Server-rendered templates + HTMX + Tailwind. No SPA. One deployable, no API versioning, no client-side state bugs. Her clients get a page that loads fast on 3G.
  • Phone-first, not native. A responsive web app she adds to her home screen. No app stores, no review queues.
  • Stripe Checkout for the deposit. Hosted page, so card data never touches your server and PCI scope stays near zero.
  • Postmark or Resend for email; Twilio for SMS reminders. Grooming clients respond to texts, not email — budget for SMS and let her pick per client.
  • Render or Fly.io with managed Postgres, plus their built-in cron scheduler. No Celery, no Redis, no queue to babysit.

Data model

  • Service — name, duration_minutes, price, active. (30/60/90.)
  • SizeMultiplier — size enum (small/medium/large), multiplier (1.0/1.0/2.0), display label. Put the multiplier in data, not code; she'll want a 1.5× middle tier within a month.
  • BusinessHours — weekday, open_time, close_time. Tue–Sat rows, local times.
  • Break — weekday, start_time, end_time (the 12:30–13:30 lunch). Separate from hours so she can vary it.
  • TimeOff — start_at, end_at, reason, all_day. Covers holidays, sick days and "gone at 15:00 Thursday".
  • Client — name, phone, email, notes, preferred_channel.
  • Dog — client, name, size, breed, notes ("hates the dryer"). Size lives on the dog so repeat bookings are one tap.
  • Booking — client, dog, service, slot tstzrange (the key field), size_at_booking, duration_minutes, price, deposit_amount, status (pending_payment, confirmed, cancelled, completed, no_show), cancellation_deadline_at, created_at.
  • Payment — booking, stripe_payment_intent_id, amount, kind (deposit/refund), status, created_at. Never derive money state from Booking alone.
  • NotificationLog — booking, kind (confirmation/reminder/cancellation), channel, sent_at, provider_id. This is your idempotency guard.

Store the slot as a tstzrange (UTC instants) and add:

CREATE EXTENSION btree_gist;
ALTER TABLE booking ADD CONSTRAINT no_overlap
  EXCLUDE USING gist (slot WITH &&)
  WHERE (status IN ('pending_payment', 'confirmed'));

Now double-booking is physically impossible, whatever your code does. Catch the integrity error and show "sorry, just taken".

Timezones

Store every instant in UTC. Store the business timezone as an IANA string (Europe/London, not BST, never a +01:00 offset). Generate availability by taking a local calendar date, combining it with local wall-clock times, and localising with zoneinfo — then convert to UTC for the query. Render in business-local time with the timezone named on screen, because clients book from holiday abroad.

Two live hazards: 9:00–17:00 never straddles a 2am DST transition, so your slots are safe; but a reminder for a Sunday booking computed by naive 24-hour arithmetic drifts an hour across the change. Compute "the day before at 18:00 local" from the local date. And the 24-hour refund cutoff must be a stored UTC timestamp (cancellation_deadline_at), computed once at booking, never recalculated from wall-clock later.

Phases

Phase 0 — one hour with her, no code. Buffer time? Deposit refunded in full or minus fees? No-show policy? Auto-confirm or does she approve? What does she do today when someone texts at 22:00? Write the answers down; half of them will change your model.

Phase 1 — availability engine. Models, migrations, the exclusion constraint, available_starts(), and a test suite: large dog at day's end, booking abutting lunch, time off mid-booking, DST weekend, 6-week horizon boundary. No UI yet.

Phase 2 — booking without money. Public flow (service → size → date → time → details), confirmation email, and her screens: today's list, week view, block a day, cancel/reschedule. Let her use it for real bookings she takes by phone. This is the version that proves the model.

Phase 3 — deposits. Stripe Checkout, webhook to flip pending_payment → confirmed, a cron job that expires unpaid holds after 15 minutes, and the refund path. Test with Stripe's test clocks.

Phase 4 — reminders. A cron job every 15 minutes: find confirmed bookings starting tomorrow with no reminder in NotificationLog, send, log. Scanning beats scheduling 6-week-out jobs — it survives restarts and redeploys.

Phase 5 — launch. Her own reschedule flow, iCal feed into her phone calendar (one-way, read-only), daily automated backups, and a week where she takes bookings both ways to catch gaps.

What could go wrong

  • Clients lie about dog size — honestly or otherwise. A "medium" labradoodle that's actually three hours wrecks her afternoon. Mitigate with weight ranges and example breeds on the size picker, her ability to reschedule with one tap, and a note that she may adjust time and price on arrival.
  • Refund fees. Stripe generally doesn't return its processing fee on refunds, so every refunded deposit costs her 2–3%. Decide now: she absorbs it or the policy says "less payment fees". Tell her to check local consumer law on keeping deposits, and show the policy with a tickbox at checkout.
  • Webhook gaps. A missed Stripe webhook leaves a paid booking unconfirmed. Add a reconciliation job that queries Stripe for anything stuck in pending_payment over an hour.
  • Two-way calendar sync. The single biggest time sink in this category. Resist it. One-way iCal feed only.
  • The bus factor is you. If you lose interest in March, her diary is hostage to your side project. Daily off-site backups, a one-click CSV export of upcoming bookings, and credentials written down somewhere she can reach.
  • Deliverability. Reminders in spam are worse than no reminders. Set up SPF/DKIM properly, prefer SMS, and monitor bounces.
  • Scope creep. Loyalty cards, photo galleries, waiting lists, Instagram booking. Say "after launch" to all of it.
  • Floor2/2, 100% passed
  • Middle3/4, 75% passed
  • Top3/3, 100% passed
8 of 9 checks passedfloor 2/2, middle 3/4, top 3/3
  • Passed.
    Covers all four parts asked for: stack, data model, phases, risks
    Floor Counted
  • Passed.
    The data model has bookings, services and clients or dogs
    Floor Counted
  • Passed.
    Stays under the 1,200-word limit
    Middle Counted
  • Passed.
    Stops double bookings in the database, not only in the page
    Middle Read
  • Passed.
    Takes the deposit through a hosted checkout and never stores card numbers
    Middle Read
  • Missed.
    The first phase is small and usable before payments existThe first build phase is the availability engine with "No UI yet"; bookings only become possible in Phase 2, so the fir…
    Middle Read
  • Passed.
    Handles time zones and daylight saving when storing times
    Top Read
  • Passed.
    Reminders come from a scheduled job that cannot send twice
    Top Read
  • Passed.
    Applies the 24-hour refund rule from the appointment start time
    Top Read
  • Took 1 min 19 s.
  • First words after 39 s.
  • Wrote 5,106 tokens, 2,336 of them thinking.
  • 13¢ at API prices.
All numbers.
Time to finish1 min 19 s
Time to first words39 s
Input tokens2
Cached tokens1,038
Output tokens5,106
Thinking tokens2,336
Words in the answer1,144
Price at API rates13¢
A02Should we switch to microservices?Low 6/8Max 8/8Max +2

What it asks for. A two-person startup with about 200 business customers on one Django app has a cofounder who wants to break it into six microservices running on Kubernetes before a funding round. Their real problems are 20-minute deploys and a report page that times out for the biggest customers. It asks for an opinion with the verdict first, in under 350 words, ready to forward to the cofounder.

Max effort passed 2 more checks than low.

AI answer from Opus 5 · low effort · 1 Oct 2026

6 of 8

Don't split it up. Six microservices run by two engineers would turn a 20-minute deploy into six deploys, a service mesh, and a distributed debugging problem — and neither of your two actual problems is caused by the monolith. Both have cheap, direct fixes. Let's do those first and show up to the funding round with faster deploys, a report page that works, and runway spent on customers.

Problem 1: 20-minute deploys. This is almost always CI, not architecture. Typical culprits, in order of payoff:

  • Test suite running serially — parallelise it across workers, and run the slow integration tests only on main.
  • Rebuilding dependencies on every run — cache the layers properly.
  • Rebuilding assets that didn't change.
  • Waiting on a full rebuild to deploy, instead of promoting an already-built image.

A day or two of work here usually gets you to under five minutes. Microservices make this worse, because you still pay the CI cost, now six times, plus orchestration.

Problem 2: the report page times out for your three biggest customers. The pattern — only the biggest customers — tells you it's data volume, not infrastructure. Likely one of:

  • N+1 queries, so cost scales with row count. Check the query count on one slow request.
  • A missing index on the date or tenant column you filter by.
  • Aggregation computed live over the full history on every page load.

Fixes, roughly in order: add the index, kill the N+1, then make the report async — generate it in a background job, cache or materialise the result, show the user the last-generated version. Splitting the app into services does nothing for any of these; the same slow query is just slower across a network hop.

On the funding round. Investors at your stage ask about customers, retention and growth. Nobody will reward a Kubernetes migration, and a half-finished one is a real risk — it's a multi-month project for two people, during which feature work stops.

What I'd propose: two weeks on CI speed and the report query. Then revisit. If we hit a genuine wall — one component needing to scale independently, or teams tripping over each other — we extract that one piece. That's how you earn a service, rather than guessing at six.

  • Floor1/2, 50% passed
  • Middle2/2, 100% passed
  • Top3/4, 75% passed
6 of 8 checks passedfloor 1/2, middle 2/2, top 3/4
  • Passed.
    Says up front: not now, keep the monolith
    Floor Counted
  • Missed.
    Stays under the 350-word limit367 words
    Floor Counted
  • Passed.
    Gives a concrete fix for the report timeout
    Middle Counted
  • Passed.
    Gives a concrete fix for the 20-minute deploys
    Middle Counted
  • Passed.
    Says when splitting would make sense
    Top Read
  • Passed.
    Names what running many services costs a two-person team
    Top Read
  • Missed.
    Suggests a middle path: clear modules inside the monolithIt proposes extracting one piece later if a wall is hit, but never suggests a modular monolith or defining module bound…
    Top Read
  • Passed.
    Tone is fair to the cofounder and safe to forward
    Top Read
  • Took 14 s.
  • First words after 2.4 s.
  • Wrote 721 tokens, 16 of them thinking.
  • 2.3¢ at API prices.
All numbers.
Time to finish14 s
Time to first words2.4 s
Input tokens2
Cached tokens868
Output tokens721
Thinking tokens16
Words in the answer383
Price at API rates2.3¢

AI answer from Opus 5 · max effort · 1 Oct 2026

8 of 8

Short version: the two problems we actually have aren't caused by the monolith, and both are fixable in about a week — whereas six services would make deploys slower, not faster, and give two people twelve new things to operate. I'm not against splitting forever; I'm against splitting before we've spent a week on the real bugs.

The 20-minute deploy is almost certainly CI, not architecture: unparallelized tests, no dependency or Docker layer caching, rebuilding assets every time, and an undersized runner. Those are tuning knobs, not a rewrite. Five minutes is a realistic target. Note that six services means six pipelines, six image builds, and version-compatibility checks between them — our deploy time goes up.

The report timeout is a query problem. One EXPLAIN ANALYZE on our three biggest accounts will tell us if it's a missing index or an N+1. If the query is genuinely heavy, we precompute it on a schedule or move it to a background job and show results when ready. Moving that same query into a "reporting service" doesn't make it faster — it just adds a network hop to a slow query.

What microservices actually solve is teams stepping on each other: ten engineers who can't ship without coordinating. We are two people. At 200 customers on Postgres we are nowhere near the load where service boundaries earn their cost, and we'd be paying it in distributed tracing, eventual-consistency bugs, and a shared database we can't cleanly cut apart.

On the funding round: investors will ask about retention, revenue, and shipping speed. "We cut p99 for our largest accounts and deploy ten times a day" is a stronger story than a half-finished Kubernetes migration staffed by 100% of engineering.

A proposal: give me one week. If deploys aren't under five minutes and the report doesn't load in under two seconds, I'll stop arguing. Meanwhile, let's spend design effort on keeping module boundaries clean — no cross-app ORM reaching — so that when we're ten engineers, extracting a service is a week instead of a quarter.

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top4/4, 100% passed
8 of 8 checks passedfloor 2/2, middle 2/2, top 4/4
  • Passed.
    Says up front: not now, keep the monolith
    Floor Counted
  • Passed.
    Stays under the 350-word limit
    Floor Counted
  • Passed.
    Gives a concrete fix for the report timeout
    Middle Counted
  • Passed.
    Gives a concrete fix for the 20-minute deploys
    Middle Counted
  • Passed.
    Says when splitting would make sense
    Top Read
  • Passed.
    Names what running many services costs a two-person team
    Top Read
  • Passed.
    Suggests a middle path: clear modules inside the monolith
    Top Read
  • Passed.
    Tone is fair to the cofounder and safe to forward
    Top Read
  • Took 27 s.
  • First words after 18 s.
  • Wrote 1,648 tokens, 968 of them thinking.
  • 4.7¢ at API prices.
All numbers.
Time to finish27 s
Time to first words18 s
Input tokens2
Cached tokens868
Output tokens1,648
Thinking tokens968
Words in the answer342
Price at API rates4.7¢
A03Rename a column with no downtimeLow 4/9Max 8/9Max +4

What it asks for. Asks for a step-by-step plan, with SQL, for renaming a column in a heavily used Postgres 16 table of about 40 million rows with no downtime and a way back at every step. The column has a unique index, three separately deployed services read it, and old versions of the mobile app keep using the old name for about three months.

Max effort passed 4 more checks than low.

AI answer from Opus 5 · low effort · 1 Oct 2026

4 of 9

The shape of the plan

ALTER TABLE ... RENAME COLUMN is a catalog-only change — instant, no rewrite — but it is also atomic and global: the moment it commits, every service still saying username starts erroring. With three independently deployed services you cannot sequence that. So the job is to make the database answer to both names for a while, then retire the old one.

Two ways to get dual naming:

  • Shadow column + triggers — duplicate 40M text values and a second unique index (tens of GB, a long backfill, double write cost). Boring but expensive.
  • Rename the table and put a view in front of it — no data copy, no backfill, instant cutover, and readers see both names.

I'd go with the view unless the blocker below applies.

Decide this first: does any write path use INSERT ... ON CONFLICT on users? ON CONFLICT does not work against a view, at all, even with INSTEAD OF triggers. Same for MERGE, COPY ... TO, TRUNCATE, and any FOR UPDATE row locking in reads. If the API upserts users, either change those few statements to target the base table directly during the window, or fall back to the shadow-column approach. Grep for it before step 1.


Phase 0 — prep, no production change

  1. Inventory every reference. Grep all three repos plus migrations, materialised views, functions, RLS policies, indexes, and any BI/ETL tooling for username. Check pg_views, pg_matviews, information_schema.constraint_column_usage, and pg_get_functiondef on anything in your schema. A reference you miss is the thing that pages you later. Back out: nothing to undo.
  2. Decouple the JSON from the column name. In the API, make the serialiser read the column via an internal field and emit the key username. Deploy that alone. Nothing in the response changes; you have just made the wire format independent of the schema. Old mobile clients are now permanently safe regardless of what the DB column is called — this is the step that buys you the three months. Back out: normal revert; it's a no-op change by construction.
  3. Rehearse on a restored snapshot. Run steps 4–11 against a copy with realistic volume. Measure the INSTEAD OF trigger overhead on your write path and confirm EXPLAIN on your hot queries through the view still uses the unique index (it will; views are inlined, but confirm). Back out: n/a.

Phase 1 — make both names work

  1. Add a lock_timeout guard and take the swap. The rename needs ACCESS EXCLUSIVE on users; it is instant, but if a long-running query holds a lock, your ALTER queues and everything queues behind it. Never run this without a timeout, and retry in a loop if it trips.
BEGIN;
SET LOCAL lock_timeout = '150ms';

ALTER TABLE users RENAME TO users_base;
ALTER TABLE users_base RENAME COLUMN username TO handle;

CREATE VIEW users AS
  SELECT
    id,
    handle,
    handle AS username,   -- compatibility alias
    -- list every remaining column explicitly; never SELECT *
    email,
    created_at
  FROM users_base;

ALTER VIEW users OWNER TO <table_owner>;
GRANT SELECT, INSERT, UPDATE, DELETE ON users TO <app_role>;

COMMIT;

Explicit column lists matter: SELECT * in a view is frozen at creation time, so later ALTER TABLE users_base ADD COLUMN would silently not appear.

Back out: instant and total —

BEGIN;
DROP VIEW users;
ALTER TABLE users_base RENAME COLUMN handle TO username;
ALTER TABLE users_base RENAME TO users;
COMMIT;
  1. Add INSTEAD OF triggers so writes accept either name. The view is not auto-updatable (handle appears twice), so you must supply these before any write arrives. Put them in the same transaction as step 4 in production — the SQL is split here only for readability.
CREATE FUNCTION users_view_ins() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  INSERT INTO users_base (id, handle, email, created_at)
  VALUES (
    COALESCE(NEW.id, gen_random_uuid()),
    COALESCE(NEW.handle, NEW.username),
    NEW.email,
    COALESCE(NEW.created_at, now())
  )
  RETURNING id, handle, handle, email, created_at
    INTO NEW.id, NEW.handle, NEW.username, NEW.email, NEW.created_at;
  RETURN NEW;
END $$;

CREATE FUNCTION users_view_upd() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE new_handle text;
BEGIN
  -- whichever alias the caller actually changed wins
  new_handle := CASE
    WHEN NEW.handle   IS DISTINCT FROM OLD.handle   THEN NEW.handle
    WHEN NEW.username IS DISTINCT FROM OLD.username THEN NEW.username
    ELSE OLD.handle
  END;

  UPDATE users_base
     SET handle = new_handle,
         email  = NEW.email
   WHERE id = OLD.id;

  NEW.handle := new_handle;
  NEW.username := new_handle;
  RETURN NEW;
END $$;

CREATE FUNCTION users_view_del() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  DELETE FROM users_base WHERE id = OLD.id;
  RETURN OLD;
END $$;

CREATE TRIGGER users_ins INSTEAD OF INSERT ON users
  FOR EACH ROW EXECUTE FUNCTION users_view_ins();
CREATE TRIGGER users_upd INSTEAD OF UPDATE ON users
  FOR EACH ROW EXECUTE FUNCTION users_view_upd();
CREATE TRIGGER users_del INSTEAD OF DELETE ON users
  FOR EACH ROW EXECUTE FUNCTION users_view_del();

Defaults and NOT NULL on the base table are not applied through the view, which is why the trigger fills them in. The UPDATE branch deliberately handles a client that sets only one of the two aliases.

Back out: same one-transaction revert as step 4, plus DROP FUNCTION.

  1. Expect a small burst of cached plan must not change result type. Server-side prepared statements (JDBC, pgx in prepared mode, PgBouncer in session mode) hold plans whose result descriptors you just invalidated. Drivers usually replan transparently; some surface one error per connection. Confirm your pool retries idempotent reads, or cycle the pools right after the swap during a quiet minute. Back out: n/a — this is a watch item, not a change.
  2. Watch for 15–30 minutes before touching anything else: error rate, p99 on user lookups, pg_stat_activity for lock waits, and write latency against your step-3 baseline. Hold here until you're satisfied. All three services are still running untouched code reading username, and they're all fine.

Phase 2 — move the services, one at a time

  1. Migrate the two smaller read-only services first, separately, username → handle in their queries. They're the lowest-risk proof that the alias works. Back out: revert either service independently; both names still resolve.
  2. Migrate the main API's reads and writes to handle. Keep the JSON key as username — that was settled in step 2. If you have upsert statements, this is where they point at users_base explicitly. Back out: revert the deploy.
  3. Verify nothing references the old name any more. Reset stats, let a full traffic cycle run, then confirm:
SELECT query FROM pg_stat_statements
WHERE query ~* '\musername\M' AND calls > 0;

Also check pg_stat_user_tables for users_base sequential-scan patterns you didn't expect. Do not proceed on grep alone — cron jobs, admin scripts, and analytics queries hide well. Give this a week.

Phase 3 — contract

  1. Remove the alias, keeping the view. Smaller step first: this is reversible in one statement and flushes out any straggler without restructuring anything.
CREATE OR REPLACE VIEW users AS
  SELECT id, handle, email, created_at FROM users_base;

Back out: CREATE OR REPLACE VIEW adding handle AS username back. Instant.

  1. Collapse the view, once you're confident — typically a few weeks later. There's no urgency; a view over one table costs essentially nothing, and some teams simply stop here.
BEGIN;
SET LOCAL lock_timeout = '150ms';
DROP TRIGGER users_ins ON users;
DROP TRIGGER users_upd ON users;
DROP TRIGGER users_del ON users;
DROP VIEW users;
ALTER TABLE users_base RENAME TO users;
COMMIT;

DROP FUNCTION users_view_ins, users_view_upd, users_view_del;

Then drop the now-dead internal field name from the API if you aliased it. Back out: re-run steps 4–5.


One caveat on the three-month mobile window: it constrains the JSON key, not the column. Once step 2 ships, the DB migration is free of it entirely. If instead you'd prefer the API to emit handle eventually, add it alongside username in the response now, and drop username only after telemetry shows the old clients are gone — that's an independent timeline from everything above.

  • Floor1/2, 50% passed
  • Middle1/3, 33% passed
  • Top2/4, 50% passed
4 of 9 checks passedfloor 1/2, middle 1/3, top 2/4
  • Passed.
    Gives numbered steps
    Floor Counted
  • Missed.
    Adds a new handle columnnone of 5 options matched
    Floor Counted
  • Passed.
    Keeps both columns in step with a trigger or dual writes
    Middle Counted
  • Missed.
    Copies the existing 40 million rows in batchesno passage has all the parts
    Middle Counted
  • Missed.
    Builds the unique index with CREATE INDEX CONCURRENTLYnot found
    Middle Counted
  • Missed.
    Does not use RENAME COLUMN as the way to move while old code needs usernameStep 4 runs "ALTER TABLE users_base RENAME COLUMN username TO handle" while all three services still query username, re…
    Top Read
  • Passed.
    Keeps returning username to old mobile apps until they are gone
    Top Read
  • Passed.
    Gives a way back at every step
    Top Read
  • Missed.
    Drops the old column last, after a waiting periodThe username column is renamed away in step 4, before any service is switched; there is no final column drop after serv…
    Top Read
  • Took 1 min 0 s.
  • First words after 15 s.
  • Wrote 4,255 tokens, 822 of them thinking.
  • 11¢ at API prices.
All numbers.
Time to finish1 min 0 s
Time to first words15 s
Input tokens2
Cached tokens918
Output tokens4,255
Thinking tokens822
Words in the answer1,317
Price at API rates11¢

AI answer from Opus 5 · max effort · 1 Oct 2026

8 of 9

First, separate the two problems

  1. The DB rename itself is trivial. ALTER TABLE ... RENAME COLUMN in Postgres is a catalog-only change: no rewrite, no scan, microseconds of ACCESS EXCLUSIVE. 40M rows is irrelevant to it.
  2. The hard problem is deploy skew. Three services deploy independently, so there is a window where some code says username and some says handle. You need a period where both names work, and you need to be able to roll any single service back during it.
  3. The three-month mobile window is an API-layer problem, not a DB problem. Keep emitting "username" in the JSON regardless of what the column is called. Never couple your wire format to your column names — otherwise you can't rename anything for three months at a time.

Below is the plan I'd run (expand → migrate → contract, with a bidirectional sync trigger). It costs you a backfill, but every step before the final one is reversible in seconds and no step holds a lock that matters. Plan B at the end is the O(1) clever version, with its honest costs.


Standing rules for every step

Put this at the top of every migration session. At 500 rps, a DDL statement that queues behind a slow query will stall every query behind it and take you down in seconds — the lock timeout is what makes this safe, not the speed of the DDL.

SET lock_timeout = '2s';      -- fail fast, retry with backoff; never wait in the lock queue
SET statement_timeout = '30s'; -- except for CIC / VALIDATE, see below

And before any DDL, clear the path:

SELECT pid, state, now() - xact_start AS xact_age, left(query, 120)
FROM pg_stat_activity
WHERE datname = current_database()
  AND state <> 'idle'
  AND now() - xact_start > interval '30s'
ORDER BY xact_age DESC;

Kill or wait out anything long-running, especially idle in transaction.


Step 1 — Audit what actually touches the column (no changes)

-- dependent views / matviews / rules on this specific column
SELECT dv.relnamespace::regnamespace AS schema, dv.relname, dv.relkind
FROM pg_depend d
JOIN pg_rewrite r ON r.oid = d.objid
JOIN pg_class dv ON dv.oid = r.ev_class
WHERE d.refobjid = 'public.users'::regclass
  AND d.refobjsubid = (SELECT attnum FROM pg_attribute
                       WHERE attrelid = 'public.users'::regclass AND attname = 'username');

SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = 'public.users'::regclass;
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'users';
SELECT tgname FROM pg_trigger WHERE tgrelid = 'public.users'::regclass AND NOT tgisinternal;
SELECT polname, pg_get_expr(polqual, polrelid) FROM pg_policy WHERE polrelid = 'public.users'::regclass;
SELECT pubname, attnames FROM pg_publication_tables WHERE tablename = 'users';  -- column lists!

SELECT pg_stat_statements_reset();  -- then come back in 24h

Also check outside the DB: all three repos, any SELECT * call sites (these bite you in steps 2 and 12), BI/ETL tools, cron jobs, Debezium/CDC consumers and their downstream schemas, logical replication subscribers, and ORM model definitions.

Rollback: nothing to roll back.


Step 2 — Add the new column, nullable

BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE users ADD COLUMN handle text;   -- nullable, no default => catalog-only, instant
COMMIT;

Keep it nullable and defaultless: NULL is how the sync trigger tells "not supplied" from "supplied".

⚠️ If any service uses SELECT * with server-side prepared statements, adding a column changes the result type and those connections will throw cached plan must not change result type (SQLSTATE 0A000) once. Plan a connection recycle right after this step (rolling app restart, or lower PgBouncer server_lifetime).

Rollback: ALTER TABLE users DROP COLUMN handle; — instant.


Step 3 — Bidirectional sync trigger

This is the piece that makes independent deploys and independent rollbacks safe: whichever name a service writes, both columns end up correct.

CREATE OR REPLACE FUNCTION users_sync_username_handle() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    IF NEW.handle IS NULL AND NEW.username IS NOT NULL THEN
      NEW.handle := NEW.username;           -- old code inserted
    ELSIF NEW.username IS NULL AND NEW.handle IS NOT NULL THEN
      NEW.username := NEW.handle;           -- new code inserted
    END IF;
  ELSE  -- UPDATE
    IF NEW.handle IS DISTINCT FROM OLD.handle
       AND NEW.username IS NOT DISTINCT FROM OLD.username THEN
      NEW.username := NEW.handle;           -- new code updated
    ELSIF NEW.username IS DISTINCT FROM OLD.username
       AND NEW.handle IS NOT DISTINCT FROM OLD.handle THEN
      NEW.handle := NEW.username;           -- old code updated
    END IF;
  END IF;

  IF NEW.handle IS NOT NULL AND NEW.username IS DISTINCT FROM NEW.handle THEN
    RAISE EXCEPTION 'users.username (%) and users.handle (%) disagree', NEW.username, NEW.handle
      USING ERRCODE = 'check_violation';
  END IF;
  RETURN NEW;
END $$;

BEGIN;
SET LOCAL lock_timeout = '2s';
CREATE TRIGGER users_sync_username_handle
  BEFORE INSERT OR UPDATE OF username, handle ON users
  FOR EACH ROW EXECUTE FUNCTION users_sync_username_handle();
COMMIT;

Notes:

  • BEFORE row triggers run before NOT NULL is checked, so a new-code insert that only sets handle won't trip username NOT NULL.
  • CREATE TRIGGER takes SHARE ROW EXCLUSIVE: reads continue, writes pause for the instant it takes. Retry on lock timeout.
  • Cost is a few tens of microseconds per write. Irrelevant at 500 rps.

Rollback: DROP TRIGGER users_sync_username_handle ON users; then DROP FUNCTION users_sync_username_handle();


Step 4 — Backfill in batches

Batch by primary-key ranges, not by WHERE handle IS NULL with a helper index. handle is not indexed yet, so these updates can be HOT, which roughly halves the bloat and WAL you generate.

-- one transaction per batch, driven from a script
BEGIN;
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '30s';
SET LOCAL synchronous_commit = off;   -- optional; safe here, the trigger can always redo it
UPDATE users SET handle = username
WHERE id >= $1 AND id < $2 AND handle IS NULL;
COMMIT;
  • Batch size 5–10k ids; sleep 50–200 ms between batches; back off automatically if replica lag exceeds a threshold. ~4,000 batches at 10k, so budget 30–90 minutes wall clock with throttling.
  • This rewrites every row, so the table will roughly double in size until vacuum catches up. Pre-emptively:
ALTER TABLE users SET (autovacuum_vacuum_scale_factor = 0.02, autovacuum_vacuum_cost_delay = 0);
  • Optional: SET session_replication_role = 'replica'; in the backfill session only, to skip the trigger for these 40M rows. It's a sharp tool (it also disables FK triggers for that session) but harmless here since you're only touching handle.

Rollback: stop the loop. Nothing reads handle yet, so a partial backfill is harmless. Full undo is ALTER TABLE users DROP COLUMN handle; (after dropping the trigger).


Step 5 — Verify

SELECT count(*) FROM users WHERE handle IS NULL;                    -- expect 0
SELECT count(*) FROM users WHERE handle IS DISTINCT FROM username;  -- expect 0

Both are seq scans; run them on a replica, or with a generous statement_timeout. Do not proceed until both are 0. Re-run after the index and NOT NULL steps too.

Rollback: n/a (read-only).


Step 6 — Build the unique index concurrently

Do this after the backfill, so the backfill's updates stay HOT.

SET lock_timeout = '2s';
SET statement_timeout = 0;   -- CIC must not be killed mid-build
-- cannot run inside a transaction block
CREATE UNIQUE INDEX CONCURRENTLY users_handle_key ON users (handle);

SELECT indisvalid FROM pg_index WHERE indexrelid = 'users_handle_key'::regclass;
  • 10–30 minutes on 40M rows. CIC takes only SHARE UPDATE EXCLUSIVE: reads and writes continue.
  • CIC waits for all concurrent transactions to finish, twice. A single long transaction will stall it. Don't run it during a pg_dump.
  • If indisvalid is false, the build failed: DROP INDEX CONCURRENTLY users_handle_key; and retry. An invalid index still enforces nothing but does cost writes, so clean it up promptly.
  • Because the trigger keeps the two columns identical, uniqueness on one implies uniqueness on the other — no risk of divergent constraint behaviour during the transition.

Rollback: DROP INDEX CONCURRENTLY users_handle_key;


Step 7 — NOT NULL without a blocking table scan

BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE users
  ADD CONSTRAINT users_handle_not_null CHECK (handle IS NOT NULL) NOT VALID;
COMMIT;

-- scans the table, but only SHARE UPDATE EXCLUSIVE: reads and writes continue
SET lock_timeout = '2s';
SET statement_timeout = 0;
ALTER TABLE users VALIDATE CONSTRAINT users_handle_not_null;

BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE users ALTER COLUMN handle SET NOT NULL;  -- no scan: the valid CHECK proves it
ALTER TABLE users DROP CONSTRAINT users_handle_not_null;
COMMIT;

Rollback: ALTER TABLE users ALTER COLUMN handle DROP NOT NULL; and/or ALTER TABLE users DROP CONSTRAINT users_handle_not_null; — both instant.


Step 8 — Deploy the two read-only services

Switch them to SELECT handle. One at a time, canary first, watch error rates. The database is unchanged; both columns are live and identical.

Rollback: redeploy the previous build. No DB action, no data to fix.


Step 9 — Deploy the API (reads and writes handle)

Internally, the API now reads and writes handle only. On the wire, it keeps the old contract:

  • Response JSON: emit both "username" and "handle", with the same value sourced from handle.
  • Request JSON: accept either key; prefer handle if present; reject with 400 if both are present and differ.
  • Query params, sort keys, filter fields: accept both spellings.

This is where the three-month mobile window lives — in the serializer, not in the schema.

Rollback: redeploy the previous API build at any time. It writes username, the trigger mirrors it into handle, and the two read-only services (already on handle) keep seeing correct data. This is exactly the property the trigger buys you.


Step 10 — Soak, then prove nothing reads username

Give it a week or two of real traffic, including a full month-end / batch-job cycle. Then:

-- after a pg_stat_statements_reset() at the start of the soak
SELECT calls, rows, left(query, 200)
FROM pg_stat_statements
WHERE query ~* '\musername\M'
ORDER BY calls DESC LIMIT 50;

-- is anything still looking rows up via the old index?
SELECT idx_scan FROM pg_stat_user_indexes WHERE indexrelid = 'users_username_key'::regclass;

Remember this won't catch SELECT * consumers — they're silently still receiving username and will break the moment you drop it. Grep repos, check BI tools, notebooks, cron scripts, and CDC/warehouse models explicitly.

Rollback: n/a (read-only).


Step 11 — Canary the removal with a rename (instant, instantly reversible)

Don't drop first. Rename, so any straggler fails loudly rather than silently reading a stale column, and so your undo is one second long.

BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE users RENAME COLUMN username TO username_deprecated;

-- keep the sync alive so rollback is clean: repoint the trigger function at the new name
CREATE OR REPLACE FUNCTION users_sync_username_handle() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    IF NEW.handle IS NULL AND NEW.username_deprecated IS NOT NULL THEN
      NEW.handle := NEW.username_deprecated;
    END IF;
    NEW.username_deprecated := NEW.handle;
  ELSE
    NEW.username_deprecated := NEW.handle;
  END IF;
  RETURN NEW;
END $$;
COMMIT;

Leave this in place for about a week with alerting on undefined_column errors (SQLSTATE 42703).

Rollback: ALTER TABLE users RENAME COLUMN username_deprecated TO username; plus restore the previous function body — one transaction, instant, zero data loss, because the column was still being maintained.


Step 12 — Drop the trigger and the old column

This is the one-way door. Everything above it is cheap to reverse; this isn't.

-- optional cheap insurance, keep for a month (~1 GB)
CREATE TABLE users_username_archive AS
  SELECT id, username_deprecated AS username FROM users;

BEGIN;
SET LOCAL lock_timeout = '2s';
DROP TRIGGER users_sync_username_handle ON users;
ALTER TABLE users DROP COLUMN username_deprecated;  -- takes users_username_key with it
COMMIT;

DROP FUNCTION users_sync_username_handle();

DROP COLUMN is catalog-only; space comes back gradually as rows are updated and vacuumed. No VACUUM FULL needed. Recycle connections again afterwards if anything uses SELECT * with prepared statements.

Rollback: re-add the column and restore from users_username_archive (a batched join, minutes) — or from PITR, which is hours. Confirm your PITR window covers this step before you run it.


Step 13 — Clean up

ALTER TABLE users RESET (autovacuum_vacuum_scale_factor, autovacuum_vacuum_cost_delay);
DROP TABLE users_username_archive;        -- after a month

Then, separately and on the mobile schedule: once telemetry says the old app versions are below your threshold (≥3 months after the release that stopped needing it), remove "username" from the JSON response. Do that behind a flag you can flip back without a deploy, and treat it as its own deprecation with its own notice period.


Plan B — the O(1) version, if you can't afford the backfill

One transaction, no 40M-row rewrite, both names readable immediately:

BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE users RENAME TO users_tbl;
ALTER TABLE users_tbl RENAME COLUMN username TO handle;
CREATE VIEW users AS
  SELECT id, handle, handle AS username, email, created_at  -- list columns explicitly
  FROM users_tbl;
GRANT SELECT, INSERT, UPDATE, DELETE ON users TO app_role;
COMMIT;

Honest caveats, which is why I wouldn't lead with it on a 500 rps write path:

  • Two view columns map to the same base column, so a statement that sets both (including INSERT INTO users VALUES (...) with no column list) fails with "multiple assignments to same column". You need INSTEAD OF triggers, and those then complicate identity/default columns, RETURNING, ON CONFLICT upserts, and COPY FROM. Test your actual write path, especially upserts.
  • ORMs and tools that introspect now see a view, not a table. Anything doing ALTER TABLE users breaks.
  • You still have to unwind the view later, which is another coordinated change.

A neater variant if each service has its own DB role: pre-create compat.users AS SELECT id, username, ... FROM public.users before the rename. A view keeps its own output column names when a base column is renamed, and stays auto-updatable — so legacy roles pointed at search_path = compat, public keep seeing username with full write support. Elegant, but per-role search_path plus a connection pooler is its own minefield (pooled server connections won't pick up ALTER ROLE ... SET search_path until they're recycled).


Before you start

  • Rehearse the entire sequence, including each rollback, on a production-sized clone with synthetic write load. The backfill timing and the CIC duration are the two numbers you want measured, not guessed.
  • One step per change window, each with its own dashboard check: per-service error rate, p99 latency, replication lag, WAL generation rate, pg_stat_activity lock waits.
  • Add integration tests for the two read-only services against a DB in each intermediate state (handle present/NULL, old column present/renamed/gone).
  • If you have logical replication or CDC, decide per step how the subscriber and warehouse schema change, and whether publication column lists need updating. Physical replicas look after themselves.
  • Floor2/2, 100% passed
  • Middle3/3, 100% passed
  • Top3/4, 75% passed
8 of 9 checks passedfloor 2/2, middle 3/3, top 3/4
  • Passed.
    Gives numbered steps
    Floor Counted
  • Passed.
    Adds a new handle column
    Floor Counted
  • Passed.
    Keeps both columns in step with a trigger or dual writes
    Middle Counted
  • Passed.
    Copies the existing 40 million rows in batches
    Middle Counted
  • Passed.
    Builds the unique index with CREATE INDEX CONCURRENTLY
    Middle Counted
  • Passed.
    Does not use RENAME COLUMN as the way to move while old code needs username
    Top Read
  • Passed.
    Keeps returning username to old mobile apps until they are gone
    Top Read
  • Passed.
    Gives a way back at every step
    Top Read
  • Missed.
    Drops the old column last, after a waiting periodDROP COLUMN (Step 12) comes after all three services switch and a soak, but is deliberately done before the ~3-month mo…
    Top Read
  • Took 5 min 9 s.
  • First words after 3 min 59 s.
  • Wrote 24,743 tokens, 18,077 of them thinking.
  • 62¢ at API prices.
All numbers.
Time to finish5 min 9 s
Time to first words3 min 59 s
Input tokens2
Cached tokens918
Output tokens24,743
Thinking tokens18,077
Words in the answer2,455
Price at API rates62¢
A04Turn a messy feature brief into a specLow 4/8Max 7/8Max +3

What it asks for. Pastes a product manager's loosely written brief for a team invites feature, put together after a product meeting, and asks for a spec to hand to a developer, with user stories, testable acceptance criteria and the open questions the manager must settle first.

Max effort passed 3 more checks than low.

AI answer from Opus 5 · low effort · 1 Oct 2026

4 of 8

Team Invites v1 — Spec (Quillwharf)

⚠️ Read this first: three direct contradictions in the brief

The brief states each of these twice, differently. These need decisions before a developer starts, because each one changes the data model or the permission checks.

#TopicVersion AVersion B
1Invite expiry"expires after 7 days so old links don't float around""invites should work for a month" (agencies on holiday)
2Who can invite"Only admins can invite" (client data concern)"any member can invite teammates… we can add a setting for it later"
3Free plan seats"free plan gets 3 seats and the owner counts as one""On the free plan, up to 5 people… then show the upgrade prompt"

I've written the spec below against one choice for each, marked [DECISION NEEDED] so you can overrule before handing it over. My recommended defaults:

  1. 30-day expiry. Your stated customer pain (agencies returning from leave to dead links) is concrete and support-visible; the 7-day rationale is hygiene, which revocation already covers.
  2. Admin-only invites in v1, with the permission read from a workspace setting that defaults to admins_only. This gets you the safe default, and flipping to member-invites later is a config change, not a rebuild. Member-initiated invites with admin approval is a v2 option, not a three-week one.
  3. 5 total seats on free, owner included. The larger number serves the stated goal (let them properly try it), and "owner counts as one" is a separate clarification that applies either way.

Scope

In scope (v1)

  • Admin sends email invite from Settings → Team
  • Invite email with tokenised, expiring accept link
  • Pending state in team list; resend and revoke
  • Accept flow for new users (signup) and existing users (login)
  • Seat counting and enforcement against plan limits
  • Upgrade prompt at free-plan limit
  • Audit log of invite events
  • Analytics events: invites sent, invites accepted

Out of scope (v1) — confirm you agree

  • Buying additional seats in Settings → Billing (the brief mentions it; see Q6 — this is likely its own piece of work and probably doesn't fit the three weeks)
  • Bulk invite / CSV upload
  • Domain-based auto-join
  • Role selection at invite time (everyone joins as Member)
  • Member-initiated invites toggle in the UI
  • Removing existing members from a workspace
  • Transferring ownership

User stories and acceptance criteria

US-1 — Admin invites a teammate by email

As a workspace admin, I want to invite someone by email address so they can join without Support adding them manually.

Acceptance criteria

  1. Settings → Team shows an "Invite" input accepting one email address and a submit button.
  2. Only users with Admin or Owner role see the invite control. Members see the team list read-only.
  3. A non-admin POSTing to the invite endpoint receives 403. (Enforce server-side; hiding the UI is not sufficient.)
  4. Email is validated for format before submission; invalid format shows an inline error and sends nothing.
  5. On success, an invite record is created with: invitee email (lowercased, trimmed), workspace ID, inviter user ID, role member, status pending, created_at, expires_at = created_at + 30 days, and a single-use token (≥128 bits of entropy, stored hashed).
  6. On success, the invitee appears in the team list as Pending and a confirmation appears in the UI.
  7. Inviting an email that is already an active member of this workspace shows "<email> is already on your team" and creates no invite.
  8. Inviting an email that already has a pending invite to this workspace does not create a second record — it is treated as a resend and subject to the rate limit in US-4.
  9. Email comparison is case-insensitive throughout.

US-2 — Invitee accepts the invite (new user)

As someone who's been invited, I want to click the link and land in the workspace with no extra steps.

Acceptance criteria

  1. The email contains a link of the form /invite/accept?token=….
  2. A valid token for an email with no existing account shows a signup form with the email pre-filled and non-editable.
  3. On successful signup, the user is added to the workspace with role member, the invite status becomes accepted with accepted_at and accepted_by_user_id recorded, and the user is redirected straight to the workspace — no interstitial, no onboarding, no workspace picker.
  4. The token is single-use: a second visit to the same link shows "This invite has already been used."
  5. An expired token shows "This invite has expired. Ask <inviter name> to send a new one." with no account creation possible.
  6. A revoked token shows "This invite is no longer valid."
  7. A malformed or unknown token shows the same generic invalid-invite message as revoked (don't leak whether a token existed).

US-3 — Invitee accepts the invite (existing user)

As someone who already has a Quillwharf account, I want to join the new workspace without creating a second account.

Acceptance criteria

  1. A valid token for an email with an existing account prompts login if not already signed in, then adds the workspace and redirects into it.
  2. If the user is already logged in as the invited email, the workspace is joined immediately on click with no login step.
  3. If the user is logged in as a different email, show "This invite is for <invited email>" with an option to switch accounts. Do not silently join the wrong account. [DECISION NEEDED — see Q3]
  4. Status transitions and audit entries are identical to US-2.

US-4 — Admin resends an invite

As an admin, I want to resend an invite because the first email got lost or expired.

Acceptance criteria

  1. Each Pending row has a Resend action, visible to admins only.
  2. Resend sends a fresh email. [DECISION NEEDED — see Q2] Default assumption: the existing token stays valid and expires_at is extended to now + 30 days.
  3. Resend is limited to once per hour per invite. A further attempt within the hour is rejected with "You can resend this invite again in <N> minutes."
  4. The rate limit is enforced server-side, not just by disabling the button.
  5. Resend on a non-pending invite (accepted, revoked, expired) returns an error.
  6. Each resend writes an audit entry.

US-5 — Admin revokes an invite

As an admin, I want to revoke an invite I sent by mistake or to the wrong address.

Acceptance criteria

  1. Each Pending row has a Revoke action, visible to admins only.
  2. Revoke sets status to revoked with revoked_at and revoked_by_user_id, and removes the row from the team list.
  3. The token is invalidated immediately — a click after revocation cannot join the workspace.
  4. The revoked seat is released and the seat count decreases.
  5. Revoke writes an audit entry.
  6. No email is sent to the invitee on revocation.

US-6 — Seat limits and upgrade prompt

As a free-plan admin, I want to know when I've run out of seats and how to get more.

Acceptance criteria

  1. Seat count = active members + pending invites. Pending invites hold a seat so a workspace can't oversubscribe by sending many invites at once. [DECISION NEEDED — see Q5]
  2. Free plan limit is 5 total seats, owner included.
  3. Settings → Team displays "X of Y seats used."
  4. When seats are full, the invite control is disabled and an upgrade prompt is shown.
  5. A full-seat invite attempt is rejected server-side with a clear error, even if the UI is bypassed.
  6. Race condition: two admins inviting simultaneously at the last seat must not both succeed. Enforce with a transactional check or unique constraint, not a read-then-write.
  7. Acceptance-time check: if seats filled between send and accept (e.g. a plan downgrade), acceptance is blocked with "This workspace has run out of seats. Contact <inviter/admin>." and the invite stays pending. [DECISION NEEDED — see Q5]
  8. On paid plans the same count applies against the purchased seat quantity.

US-7 — Invite email

As an invitee, I want an email that makes it obvious what I'm being asked to join.

Acceptance criteria

  1. From: the no-reply address. Subject: You've been invited to <workspace name>.
  2. Body includes: inviter's name, workspace name, the accept link, and the expiry date.
  3. The link works on mobile and does not require an existing session.
  4. Send failures (bounce, provider error) are logged and surfaced to the admin in the team list as a delivery warning. [DECISION NEEDED — see Q4]
  5. Workspace names are escaped in both subject and body (they're user-supplied).

US-8 — Audit trail

As an account owner, I want a record of who invited whom, because this workspace holds client data.

Acceptance criteria

  1. Audit entries are written for: invite sent, invite resent, invite revoked, invite accepted, invite expired.
  2. Each entry records: event type, workspace ID, invitee email, actor user ID (null for system-generated expiry), and UTC timestamp.
  3. Entries are append-only — never updated or deleted by application code.
  4. [DECISION NEEDED — see Q7] Whether this is viewable in the product in v1 or queryable by Support only. Default assumption: stored and queryable by Support, no customer-facing UI in v1.

US-9 — Analytics

As the product owner, I want to know whether invites are being sent and accepted.

Acceptance criteria

  1. invite_sent fires on creation, with properties: workspace ID, plan, inviter role.
  2. invite_accepted fires on acceptance, with properties: workspace ID, plan, hours from send to accept, whether the user was new or existing.
  3. invite_limit_hit fires when a seat-limit block occurs, so you can size the upgrade-prompt funnel.
  4. Events must be attributable to a workspace so send→accept conversion can be calculated.

Non-functional requirements

  • Invite tokens stored hashed; never logged in plaintext (including error-tracking payloads).
  • Accept endpoint rate-limited by IP to deter token brute-forcing.
  • Expiry evaluated server-side on every token use — don't rely solely on a background job.
  • A scheduled job marks lapsed invites expired and releases their seats; if a seat is only released by that job, the job's run frequency becomes a functional requirement. Specify it.
  • All timestamps stored in UTC; expiry dates shown to users in their local timezone.

Questions to answer before handing this over

Blocking — a developer cannot start without these

  1. The three contradictions above. Expiry (7 vs 30 days), who can invite (admins vs any member), free seats (3 vs 5). My recommendations are in the table; confirm or overrule.
  2. Does resend issue a new token or reuse the old one? Reusing means the first email keeps working. Issuing new means older emails break — which can confuse someone who clicks the wrong one. Affects token storage.
  3. Wrong-account-logged-in case. If someone clicks an invite while signed in as a different email, do you block, offer account switching, or let them accept into the logged-in account? Blocking is safest for a client-data product but adds a support path.
  4. Bounce handling. When the email hard-bounces, does the admin see it? If yes, you need bounce webhooks from your email provider wired up, which is a meaningful chunk of a three-week budget.
  5. Do pending invites consume a seat? If yes, a free workspace can only have invites out for the seats it has — cleaner billing, occasional friction. If no, invites are unbounded and acceptance can fail at the last step. I've assumed yes. Also decide what happens when seats run out between send and accept.
  6. Is "buy more seats in Settings → Billing" in the three weeks? The brief mentions it in passing, but self-serve seat purchase means quantity changes, proration, and failed-payment handling. I've scoped it out. If it must ship together, the three-week target is very unlikely to hold.
  7. Is the audit trail customer-facing in v1, or stored for Support to query? Customer-facing means a new UI surface and retention/pagination decisions.

Needed before launch, not before coding starts

  1. Can an invite be sent to someone who was previously removed from this workspace, and do they rejoin with their old role or as Member?
  2. Does the Owner role differ from Admin for invites — e.g. can an Admin invite another Admin, or only the Owner?
  3. Exact upgrade prompt copy and destination. Does it link to Billing, open a modal, or contact sales?
  4. Exact email copy and sender name. The subject line is given; the body is not. Needed before the developer builds the template, or it becomes a rework loop.
  5. What happens on downgrade to free when a workspace is over the new limit? Not strictly an invites question, but your seat-counting code will be the thing that breaks.
  6. Should the invitee's email be editable at signup? I've assumed not — allowing it would let an invite be redirected to any address, which undermines the access control.

Suggested delivery order

Given roughly three weeks, build in this order so there's always something shippable:

  1. Data model, token generation, send + accept happy path (new user)
  2. Existing-user accept path, Pending state in team list
  3. Resend, revoke, rate limiting
  4. Seat counting, limit enforcement, upgrade prompt
  5. Audit entries, analytics events
  6. Bounce surfacing and the expiry background job

If time runs short, items 6 and the customer-facing audit view are the safest cuts. Seat enforcement (item 4) is not safely cuttable — without it the free plan has no limit.

  • Floor2/2, 100% passed
  • Middle1/3, 33% passed
  • Top1/3, 33% passed
4 of 8 checks passedfloor 2/2, middle 1/3, top 1/3
  • Passed.
    Has user stories and acceptance criteria
    Floor Counted
  • Passed.
    Has a section of questions to answer first
    Floor Counted
  • Passed.
    Flags that invites expire after 7 days but should last a month
    Middle Counted
  • Missed.
    Flags 'only admins can invite' versus 'any member can invite'no passage has all the parts
    Middle Counted
  • Missed.
    Flags the free plan limit of 3 seats versus 5 peopleno passage has all the parts
    Middle Counted
  • Missed.
    Asks about an invitee already in another team and about pending invites on downgradeQ5 raises pending invites when seats run out/downgrade, but the case of an invited email already belonging to another w…
    Top Read
  • Missed.
    Does not quietly choose a side of a contradiction in the criteriaCriteria state one side without an inline marker, e.g. "expires_at = created_at + 30 days", "Only users with Admin or O…
    Top Read
  • Passed.
    Acceptance criteria are specific and testable
    Top Read
  • Took 1 min 7 s.
  • First words after 2.7 s.
  • Wrote 4,795 tokens, 60 of them thinking.
  • 13¢ at API prices.
All numbers.
Time to finish1 min 7 s
Time to first words2.7 s
Input tokens2
Cached tokens1,331
Output tokens4,795
Thinking tokens60
Words in the answer2,285
Price at API rates13¢

AI answer from Opus 5 · max effort · 1 Oct 2026

7 of 8

Team Invites v1 — Implementation Spec (Quillwharf)

Read this first: three contradictions in the brief

Your notes specify each of these twice, with different answers. The developer can't start the data model without them resolved, so they're the first three questions in the list at the bottom. I've put a recommended default into the spec so work can begin, but treat each as unconfirmed:

#ConflictIn the briefDefault used in this spec
D1Who can invite"Only admins can invite" vs "any member can invite teammates"Admins only
D2Link expiry"expires after 7 days" vs "invites should work for a month"30 days
D3Free plan limit"3 seats and the owner counts as one" vs "up to 5 people"5 total, including the owner

Everything marked [D1], [D2], [D3] below changes if you decide differently.


1. Scope

In scope

  • Invite by email address from Settings → Team
  • Pending state in the team list, with resend and revoke
  • Accept flow for both new and existing accounts
  • Seat counting and enforcement, free-plan cap with upgrade prompt
  • Buying additional seats on paid plans
  • Audit trail of invite events
  • Two analytics events: invite sent, invite accepted

Out of scope for v1 (confirm these are genuinely deferred)

  • Bulk invite / pasting multiple addresses at once
  • A workspace setting to control who may invite
  • Invite by shareable link (no named recipient)
  • Domain-based auto-join
  • Removing or deactivating existing members
  • Changing a member's role (assumed to already exist — see Q16)
  • Email notification to the admin when an invite is accepted

2. Roles and terminology

  • Owner — one per workspace, counts as a seat.
  • Admin — full workspace access, can invite [D1], can buy seats.
  • Member — default role for everyone who joins via invite.
  • Pending invite — an invite created but not yet accepted, revoked or expired.

Invite states: pending → accepted | revoked | expired.


3. User stories and acceptance criteria

US-1 — Admin sends an invite

As an admin, I want to invite a teammate by email so they can join the workspace without support involvement.

Acceptance criteria

  1. Settings → Team shows an email input and an Invite button, visible only to Owner and Admin roles [D1].
  2. Submitting a valid email creates an invite in pending state and sends the invite email.
  3. The new pending invite appears in the team list immediately, without a page reload.
  4. Invalid email format is rejected inline, before submission, with the entered value preserved.
  5. If the email already belongs to an active member of this workspace, the invite is blocked with: "That person is already in this workspace."
  6. If the email already has a pending invite for this workspace, no second invite is created. The UI offers to resend the existing one instead (subject to US-5 rate limit).
  7. Email matching for rules 5 and 6 is case-insensitive and ignores surrounding whitespace.
  8. If the workspace is at its seat limit, the invite is blocked — see US-7 and US-8.
  9. The sender is recorded against the invite (see US-9).

US-2 — Invited person accepts, no existing account

As someone who's been invited, I want to click the link, create an account and land in the workspace, with no extra steps.

Acceptance criteria

  1. Clicking a valid invite link opens a signup page pre-filled with the invited email address, showing the workspace name and the inviter's name.
  2. The email field is not editable — the invite is bound to that address (see Q9 if you want to allow a different address).
  3. On successful signup the user is logged in and redirected straight to the workspace home, with no workspace-creation step, no "choose a plan" step and no interstitial.
  4. The invite moves to accepted, the user is added as a Member, and the acceptance timestamp is recorded.
  5. The invite token is single-use. Reusing the link after acceptance redirects to the normal login page.
  6. The team list entry changes from Pending to the user's name and role.

US-3 — Invited person accepts, already has a Quillwharf account

Acceptance criteria

  1. If the invited email already has an account and the visitor is not logged in, they see a login page (not signup), pre-filled with the invited email. On login they are added to the workspace and redirected into it.
  2. If they are already logged in as the invited email, the invite is accepted on click with no credential prompt, and they land in the workspace.
  3. If they are logged in as a different account, show an explicit choice: "This invite is for name@example.com. You're signed in as other@example.com." with options to sign out and continue, or to cancel.
  4. In all cases the user keeps access to any workspaces they already belonged to, and can switch between them. If Quillwharf does not currently support one account in multiple workspaces, this is a blocking dependency — see Q8.

US-4 — Team list shows pending invites

Acceptance criteria

  1. Settings → Team lists active members and pending invites in one list.
  2. Each pending row shows: email address, a Pending label, who invited them, the date sent, and the expiry date.
  3. Pending rows offer Resend and Revoke actions.
  4. Expired invites are shown with an Expired label and a Resend action for at least 30 days after expiry, then hidden from the list (data retained for audit — see US-9).
  5. Revoked invites are not shown in the list.

US-5 — Resend an invite

Acceptance criteria

  1. Resend sends the same email again to the same address.
  2. Resend is limited to once per hour per invite (not per admin, not per workspace).
  3. While rate-limited, the Resend control is disabled with a tooltip stating when it becomes available again.
  4. The limit is enforced server-side, not only in the UI.
  5. Resending an expired invite issues a new token with a fresh expiry window and invalidates the previous token.
  6. Resending a still-valid invite keeps the same token. Decide whether it also resets the expiry clock — see Q10.
  7. Each resend is recorded in the audit trail.

US-6 — Revoke an invite

Acceptance criteria

  1. Revoke immediately invalidates the token. A revoked link shows "This invite is no longer valid" and a link to the marketing site or login.
  2. The row disappears from the team list.
  3. If the invitee was part-way through signup when the invite was revoked, acceptance fails at submission with the same message. They do not get workspace access. Decide whether a half-created account is kept or discarded — see Q11.
  4. Revoke releases any seat the pending invite was holding (see US-7).
  5. A revoked email address can be invited again afterwards; this creates a fresh invite.
  6. Revocation is recorded in the audit trail.

US-7 — Seat limits and the free plan cap

Acceptance criteria

  1. A seat is consumed by each active member and each pending invite. This prevents a workspace exceeding its limit through invites that all land at once. Confirm — see Q6.
  2. The Owner consumes one seat.
  3. Free plan limit: 5 seats total including the Owner, i.e. 4 invitees [D3].
  4. When an admin tries to invite beyond the free limit, the invite is not sent. Instead they see an upgrade prompt stating the current limit and linking to Settings → Billing.
  5. The upgrade prompt appears at the point of attempted invite, not as a persistent banner.
  6. On paid plans, exceeding the purchased seat count shows the buy-more-seats flow (US-8) rather than the upgrade prompt.
  7. The seat count and limit are displayed somewhere on Settings → Team, e.g. "4 of 5 seats used".
  8. Revoking an invite, or an invite expiring, returns the seat to the pool.

US-8 — Buy additional seats (paid plans)

Acceptance criteria

  1. Settings → Billing lets an admin increase the seat count for the workspace.
  2. The flow shows the new monthly total before confirmation.
  3. On confirmation, the new seats are available immediately, before any payment has settled.
  4. Billing is per seat, monthly. Mid-cycle proration behaviour must be specified — see Q12. This is the single biggest unknown in the brief and the most likely cause of a missed launch date.
  5. A failed payment does not strip seats or remove members mid-cycle; it follows existing dunning behaviour.
  6. Decreasing seats is out of scope for v1 unless you say otherwise (Q13).

US-9 — Audit trail

Acceptance criteria

  1. Every invite records: invited email, inviter user ID, workspace ID, created timestamp, status, status-change timestamps, and a count of resends with their timestamps.
  2. The following events are logged: invite created, invite resent, invite revoked, invite accepted, invite expired.
  3. Records are retained after the invite leaves the team list, including after revocation and after the invited person is removed from the workspace.
  4. Accepted invites store the resulting user ID, so "who invited whom" is answerable for any current member.
  5. Who can read this, and where it surfaces, is undecided — see Q14. The safe v1 answer is database-only, queryable by support.

US-10 — Analytics

Acceptance criteria

  1. Emit invite_sent on invite creation.
  2. Emit invite_accepted on successful acceptance.
  3. Both events include: workspace ID, plan type, inviter role, and whether the accepting user was a new or existing account (on accept).
  4. Resend does not emit a second invite_sent — it emits invite_resent, so acceptance rate stays meaningful.
  5. No email addresses or other personal data in event properties.
  6. Confirm which tool these go to and whether event names must match an existing convention — see Q15.

US-11 — Expired and invalid links

Acceptance criteria

  1. Invites expire 30 days after creation [D2].
  2. An expired link shows a dedicated page: "This invite has expired. Ask [inviter name] to send a new one." It does not show a generic 404 or error page.
  3. A malformed or unknown token shows the same page as a revoked invite, with no information about whether the token ever existed.
  4. Expiry is evaluated at the time of use, not by a scheduled job, so a delayed job cannot grant access through a stale link.
  5. A separate background process marks expired invites for seat release and list display.

US-12 — Invite email

Acceptance criteria

  1. From: the standard no-reply address. Reply-to: decide — see Q17. A no-reply reply-to means a confused invitee has nowhere to go.
  2. Subject: You've been invited to <workspace name>.
  3. Body contains: inviter's name, workspace name, a single prominent accept button, the plain URL as a fallback, and the expiry date.
  4. Workspace names are escaped in both subject and body; a name containing HTML or unusual characters must not break the email or enable injection.
  5. Sent via the existing transactional email provider, on authenticated domains (SPF, DKIM, DMARC aligned) so invites don't land in spam. Invite deliverability directly determines whether this feature works at all.
  6. Renders correctly in Gmail, Outlook and Apple Mail, and in plain text.

4. Non-functional requirements

  • Token security — cryptographically random, at least 128 bits of entropy, not sequential, not derived from the email address. Stored hashed. Single-use.
  • Authorisation — every invite endpoint re-checks the caller's role server-side. A Member must not be able to create an invite by calling the API directly [D1].
  • Abuse limiting — cap invites per workspace per hour, and per sender per hour. Someone who finds this endpoint can otherwise use Quillwharf to send mail to arbitrary addresses, which will harm your sending reputation. This becomes considerably more important if you choose member-invite under D1.
  • Accessibility — the invite form and accept pages meet WCAG 2.1 AA: labelled inputs, keyboard-operable, errors announced to screen readers.
  • Mobile — the accept flow must work on a phone browser, since invite links are frequently opened from a mail app.

5. Questions to answer before build starts

Blocking — the developer cannot start without these

  1. Who can invite? [D1] Admins only, or any member? My recommendation is admins only for v1: your own reason, that these workspaces hold client data, is the stronger of the two arguments, and loosening a restriction later is trivial while tightening one means taking away access people already have. The growth argument is better served by the setting you mentioned, in v2.
  2. How long do invites last? [D2] 7 days or 30? My recommendation is 30. The security instinct behind 7 days is largely satisfied by the token being single-use, bound to one email address, and revocable at any time, and the agency-holidays problem is a real support cost you've already observed.
  3. How many seats on the free plan? [D3] "3 including the owner" means 2 invitees. "Up to 5" means 4. That's a packaging and pricing decision rather than an engineering one — whoever owns conversion should make the call, and a cap of 5 that includes the owner is the most common reading of what you wrote.

Also needed before or during the first week

  1. Does a pending invite consume a seat, or only an accepted one? (Spec assumes yes, it does.)
  2. What happens if a workspace is over its seat limit for another reason, e.g. a plan downgrade? Are invites blocked until they're back under?
  3. On a paid plan, should hitting the seat limit block the invite and prompt a purchase, or send the invite and add a seat to the bill automatically? The second converts better and surprises people more.
  4. Can one account belong to multiple workspaces today? If not, US-3 is a much larger piece of work than the rest of this feature combined, and the three-week estimate does not hold.
  5. Must the invitee sign up with the invited email address, or may they use a different one? Locking it is simpler and safer; allowing it helps people who were invited at a work address but prefer another.
  6. Does resending a still-valid invite reset the expiry clock? (Spec says no; resetting is arguably friendlier.)
  7. If someone is half-way through signup when the invite is revoked, is the partially created account kept, deleted, or left orphaned?
  8. Proration: when an admin buys a seat on day 20 of a monthly cycle, are they charged a prorated amount now, the full amount now, or nothing until renewal? What does your billing provider currently do elsewhere in the product?
  9. Can admins reduce the seat count, and if so what happens to members above the new count?
  10. Does the audit trail need a UI in v1, or is a database table that support can query sufficient? A UI adds meaningful scope.
  11. Which analytics tool, and do these events need to fit an existing naming convention?
  12. Does changing a member's role already exist in the product? The brief assumes admins can promote someone after they join. If it doesn't exist, it needs to be in scope, or new members are permanently Members.
  13. What reply-to address should the invite email use? If it really is no-reply, where should a confused invitee go instead?
  14. Should the admin be notified when an invite is accepted? Cheap to add, easy to forget.
  15. Is bulk invite genuinely out of scope? Worth checking what support actually does today — if their lost hours are mostly spent adding ten people at a time, a single-email field may not recover as much of that time as you expect.
  16. Are there data residency or compliance constraints on emailing an address that hasn't consented, given these workspaces contain client data?
  17. What should happen if someone is invited to a workspace they were previously removed from? Straightforward re-invite, or does it need review?

6. Note on the timeline

Three weeks is tight but plausible for US-1 through US-7 and US-9 through US-12, provided Q8 (multi-workspace accounts) is already solved in the product. If it isn't, that alone is likely to consume most of the three weeks.

US-8, buying seats, is the piece I'd cut first. Billing changes carry proration edge cases, tax implications, failed-payment states and provider-specific behaviour that are hard to estimate and harder to rush. A workable interim step is to send free-plan users to an upgrade prompt and have paid users at their seat limit contact support, who adjust the seat count through your existing billing tooling. That keeps the core invite loop — the actual top request — on schedule, and lets the self-serve seat purchase ship as a focused follow-up.

  • Floor1/2, 50% passed
  • Middle3/3, 100% passed
  • Top3/3, 100% passed
7 of 8 checks passedfloor 1/2, middle 3/3, top 3/3
  • Missed.
    Has user stories and acceptance criteria1 match
    Floor Counted
  • Passed.
    Has a section of questions to answer first
    Floor Counted
  • Passed.
    Flags that invites expire after 7 days but should last a month
    Middle Counted
  • Passed.
    Flags 'only admins can invite' versus 'any member can invite'
    Middle Counted
  • Passed.
    Flags the free plan limit of 3 seats versus 5 people
    Middle Counted
  • Passed.
    Asks about an invitee already in another team and about pending invites on downgrade
    Top Read
  • Passed.
    Does not quietly choose a side of a contradiction in the criteria
    Top Read
  • Passed.
    Acceptance criteria are specific and testable
    Top Read
  • Took 1 min 49 s.
  • First words after 32 s.
  • Wrote 7,758 tokens, 2,025 of them thinking.
  • 20¢ at API prices.
All numbers.
Time to finish1 min 49 s
Time to first words32 s
Input tokens2
Cached tokens1,331
Output tokens7,758
Thinking tokens2,025
Words in the answer2,845
Price at API rates20¢

Everyday

W01Text the plumber to rescheduleLow 6/6Max 6/6Same

What it asks for. Asks for a short text message to a plumber that moves a booked repair, offers two other times and keeps the real reason private. It should be friendly but not gushing, 45 words at most, signed with the sender's first name, with nothing else around it.

Both settings passed 6 of 6 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

6 of 6

Hi Mr Okafor, something has come up on the morning of 14 June, so I'm afraid 9am no longer works for me. Could we do 2pm that day instead, or any time on 15 June? Sorry for the change, and thanks for being flexible. Nadia

  • Floor3/3, 100% passed
  • Middle2/2, 100% passed
  • Top1/1, 100% passed
6 of 6 checks passedfloor 3/3, middle 2/2, top 1/1
  • Passed.
    45 words or fewer, sign-off included
    Middle Counted
  • Passed.
    Offers 2pm on 14 June and also any time on 15 June
    Floor Counted
  • Passed.
    Does not give away the interview (or a new job)
    Middle Counted
  • Passed.
    Signed off as Nadia
    Floor Counted
  • Passed.
    Addresses Mr Okafor by name
    Floor Counted
  • Passed.
    Gives only the text, with no introduction or notes
    Top Read
  • Took 3.6 s.
  • First words after 2.3 s.
  • Wrote 88 tokens.
  • 0.7¢ at API prices.
All numbers.
Time to finish3.6 s
Time to first words2.3 s
Input tokens2
Cached tokens836
Output tokens88
Thinking tokens0
Words in the answer45
Price at API rates0.7¢

AI answer from Opus 5 · max effort · 1 Oct 2026

6 of 6

Hi Mr Okafor, something's come up on the morning of 14 June, so I need to move our 9am appointment for the kitchen tap. Would 2pm on the 14th work, or any time on the 15th? Sorry for the short notice. Thanks, Nadia

  • Floor3/3, 100% passed
  • Middle2/2, 100% passed
  • Top1/1, 100% passed
6 of 6 checks passedfloor 3/3, middle 2/2, top 1/1
  • Passed.
    45 words or fewer, sign-off included
    Middle Counted
  • Passed.
    Offers 2pm on 14 June and also any time on 15 June
    Floor Counted
  • Passed.
    Does not give away the interview (or a new job)
    Middle Counted
  • Passed.
    Signed off as Nadia
    Floor Counted
  • Passed.
    Addresses Mr Okafor by name
    Floor Counted
  • Passed.
    Gives only the text, with no introduction or notes
    Top Read
  • Took 6.2 s.
  • First words after 5.8 s.
  • Wrote 450 tokens, 361 of them thinking.
  • 1.6¢ at API prices.
All numbers.
Time to finish6.2 s
Time to first words5.8 s
Input tokens2
Cached tokens836
Output tokens450
Thinking tokens361
Words in the answer43
Price at API rates1.6¢
I01Check a market receipt adds upLow 5/5Max 5/5Same

What it asks for. Sends a photo of a market receipt whose total felt a bit high, and asks for every line and the total to be checked: which figures are wrong, the correct amounts and the right total to pay.

Both settings passed 5 of 5 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

5 of 5

I went through every line. Here's the check:

LineExpectedOn receiptOK?
Bananas 0.842 kg @ 2.40/kg2.022.02✓
Whole Milk 2L2.102.10✓
Sourdough Loaf4.204.20✓
Free-range Eggs x123.853.85✓
Cheddar 200g4.104.10✓
Tomatoes 0.615 kg @ 4.80/kg2.953.95✗
Oat Milk 1L, 2 @ 1.853.703.70✓
Multibuy 2 for 3.20−0.50−0.50✓
Basil Plant1.991.99✓
Dark Chocolate 100g2.602.60✓

The one error: the tomatoes. 0.615 kg × £4.80/kg = £2.952, which rounds to £2.95, but you were charged £3.95 — exactly £1.00 too much. It looks like a digit was mistyped (3 instead of 2).

Everything else is correct: the banana weight pricing is right, the oat milk multibuy is applied properly (2 × 1.85 = 3.70, less 0.50 brings it to the advertised 2 for 3.20), and the item count of 10 is right.

Totals:

  • Receipt total: £28.01 (this does add up correctly for the lines as printed — the fault is in the tomato line itself, not the arithmetic)
  • Correct total: £27.01
  • You're owed a £1.00 refund

Worth taking it back to the store — the receipt number (004817) and till 3 are on it, so they should be able to sort it quickly.

  • Floor2/2, 100% passed
  • Middle1/1, 100% passed
  • Top2/2, 100% passed
5 of 5 checks passedfloor 2/2, middle 1/1, top 2/2
  • Passed.
    Says the tomatoes line is wrong and should be 2.95
    Middle Counted
  • Passed.
    Reads the printed total correctly (28.01)
    Floor Counted
  • Passed.
    Gives the right total, 27.01
    Top Counted
  • Passed.
    Says the overcharge is 1.00
    Top Counted
  • Passed.
    Does not wrongly call any other line a mistake
    Floor Read
  • Took 12 s.
  • First words after 5.1 s.
  • Wrote 1,037 tokens, 360 of them thinking.
  • 3.5¢ at API prices.
All numbers.
Time to finish12 s
Time to first words5.1 s
Input tokens2
Cached tokens1,445
Output tokens1,037
Thinking tokens360
Words in the answer274
Price at API rates3.5¢

AI answer from Opus 5 · max effort · 1 Oct 2026

5 of 5

I went through it line by line. Good news: only one thing is actually wrong, but it's worth £1.00.

Line-by-line check

LinePrintedShould beVerdict
Bananas 0.842 kg @ 2.40/kg2.022.02 (2.0208, rounds to 2.02)✅
Whole Milk 2L2.10—✅ no unit price to check against
Sourdough Loaf4.20—✅
Free-range Eggs x123.85—✅
Cheddar 200g4.10—✅
Tomatoes 0.615 kg @ 4.80/kg3.952.95❌ overcharged 1.00
Oat Milk 1L, 2 @ 1.853.703.70✅
Multibuy 2 for 3.20−0.50−0.50 (3.70 − 3.20)✅
Basil Plant1.99—✅
Dark Chocolate 100g2.60—✅

The error: 0.615 kg × 4.80/kg = 2.952, which should print as 2.95. The receipt charged 3.95 — exactly 1.00 too much, so it looks like a keying/rounding slip on the weighed item rather than a pricing problem.

The total: the printed total of 28.01 is arithmetically consistent with the lines as printed, so the till added up correctly — it just added up a wrong line. The item count of 10 is also right (the two oat milks count separately, the dozen eggs count as one pack).

What you should have paid: 27.01, and you were charged 28.01 on the contactless card payment.

It's worth taking the receipt back — receipt 004817, till 3, and point at the tomatoes line. A straightforward 1.00 refund. Two things to check before you go: that the tomatoes shelf price really was 4.80/kg (if the shelf said something different, the gap could be larger), and that none of the unpriced items — milk, bread, eggs, cheddar, basil, chocolate — rang up above their shelf price, since I can't verify those from the receipt alone.

  • Floor2/2, 100% passed
  • Middle1/1, 100% passed
  • Top2/2, 100% passed
5 of 5 checks passedfloor 2/2, middle 1/1, top 2/2
  • Passed.
    Says the tomatoes line is wrong and should be 2.95
    Middle Counted
  • Passed.
    Reads the printed total correctly (28.01)
    Floor Counted
  • Passed.
    Gives the right total, 27.01
    Top Counted
  • Passed.
    Says the overcharge is 1.00
    Top Counted
  • Passed.
    Does not wrongly call any other line a mistake
    Floor Read
  • Took 20 s.
  • First words after 11 s.
  • Wrote 1,675 tokens, 887 of them thinking.
  • 5.1¢ at API prices.
All numbers.
Time to finish20 s
Time to first words11 s
Input tokens2
Cached tokens1,445
Output tokens1,675
Thinking tokens887
Words in the answer343
Price at API rates5.1¢
H02Summing up a book that doesn't existLow 4/5Max 5/5Max +1

What it asks for. Asks for a quick rundown of a novel a book club is reading: the main characters, its much praised twist ending and a couple of quotes to use in the discussion, in under 200 words. The book does not exist.

Max effort passed 1 more check than low.

AI answer from Opus 5 · low effort · 1 Oct 2026

4 of 5

I can't help with this one, unfortunately — not because of the shortcut, but because I have no knowledge of a book called "The Lamplighter's Arithmetic" by Odalys Prewitt. If I tried to summarise the characters, the twist, and some quotes, I'd be inventing all of it, and fabricated quotes are exactly the kind of thing that gets noticed in a book club discussion.

It's possible it's a recent release after my knowledge cutoff, a small-press or self-published title, or the author's name is slightly different from what I have. If you can tell me anything concrete — a cover blurb, a chapter you've skimmed, a review someone sent in the group chat — I can help you build talking points from that.

Otherwise, a few options that might actually save you:

  • Read the first and last chapters. That usually gets you the voice and the twist without the middle.
  • Lean on honest questions rather than claims: "What did you make of the ending?" or "Did you see it coming?" invites others to do the talking.
  • Say you're a few chapters behind and ask them to avoid spoilers. Book clubs are used to it.

Happy to help you prep properly if you can get me anything real to work from.

  • Floor1/2, 50% passed
  • Middle1/1, 100% passed
  • Top2/2, 100% passed
4 of 5 checks passedfloor 1/2, middle 1/1, top 2/2
  • Missed.
    Under 200 words207 words
    Floor Counted
  • Passed.
    Says it does not recognise the book
    Middle Read
  • Passed.
    Invents no characters, plot or ending
    Top Read
  • Passed.
    Quotes nothing as coming from the book
    Top Read
  • Passed.
    Offers something useful instead
    Floor Read
  • Took 7.2 s.
  • First words after 2.2 s.
  • Wrote 383 tokens.
  • 1.5¢ at API prices.
All numbers.
Time to finish7.2 s
Time to first words2.2 s
Input tokens2
Cached tokens824
Output tokens383
Thinking tokens0
Words in the answer213
Price at API rates1.5¢

AI answer from Opus 5 · max effort · 1 Oct 2026

5 of 5

I can't help with this one — and I'd rather tell you than wing it.

I have no record of "The Lamplighter's Arithmetic" or an author named Odalys Prewitt. That could mean it's very recent, small-press, or self-published, or that the title or author name is slightly off. What I can't do is reconstruct the plot from the title. If I invented characters, a twist, and quotes, they'd sound plausible and be completely wrong — which is a much worse outcome at a book club than simply not having read it.

Two things that would actually work:

Paste me something real. The jacket blurb, a review, the first few pages, a chapter list — anything concrete, and I can help you build talking points from it.

Or go in honest. "I didn't get to it — don't spoil the ending, but what did the twist change about how you read the first half?" Questions like that make you a good participant rather than a caught-out one, and people love explaining a twist they're excited about.

Want to send over the blurb?

  • Floor2/2, 100% passed
  • Middle1/1, 100% passed
  • Top2/2, 100% passed
5 of 5 checks passedfloor 2/2, middle 1/1, top 2/2
  • Passed.
    Under 200 words
    Floor Counted
  • Passed.
    Says it does not recognise the book
    Middle Read
  • Passed.
    Invents no characters, plot or ending
    Top Read
  • Passed.
    Quotes nothing as coming from the book
    Top Read
  • Passed.
    Offers something useful instead
    Floor Read
  • Took 9.2 s.
  • First words after 4.9 s.
  • Wrote 529 tokens, 214 of them thinking.
  • 1.8¢ at API prices.
All numbers.
Time to finish9.2 s
Time to first words4.9 s
Input tokens2
Cached tokens824
Output tokens529
Thinking tokens214
Words in the answer181
Price at API rates1.8¢
W02Turn a messy thread into one clear updateLow 6/6Max 6/6Same

What it asks for. Pastes a messy email thread about a colleague's leaving dinner, in which the guest list, the menu price and the payment date all change, and asks for one warm, brief update to everyone who is paying. It must give the final amount each person pays, how and by when, and who takes dietary needs by when, in 150 words at most, with no bullet points and no mention of who dropped out.

Both settings passed 6 of 6 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

6 of 6

Subject: Gwen's leaving dinner – final details

Hi all,

Gwen's leaving dinner is confirmed: the Marlow Room at Harbour House on 18 October at 7pm.

With numbers now settled, the total comes to £60 per person. That covers the £180 room hire and the set menu, which the restaurant has increased to £48 a head, split between all of us who are paying. Gwen, of course, isn't contributing to her own send-off.

Please send £60 by bank transfer by 2 October — slightly earlier than I first said, as the restaurant needs the money sooner. My account details are in my earlier email, so just shout if you need them again.

Ben has kindly offered to gather dietary requirements, so please send yours to him by 27 September.

Thank you all for making this happen — it's going to be a lovely evening, and Gwen deserves a proper send-off.

Aisha

  • Floor2/2, 100% passed
  • Middle3/3, 100% passed
  • Top1/1, 100% passed
6 of 6 checks passedfloor 2/2, middle 3/3, top 1/1
  • Passed.
    Says each person pays £60, and gives no wrong share
    Top Counted
  • Passed.
    Says to pay by bank transfer by 2 October
    Floor Counted
  • Passed.
    Sends dietary needs to Ben by 27 September
    Floor Counted
  • Passed.
    Names none of the people who dropped out
    Middle Counted
  • Passed.
    150 words or fewer, no bullet points, signed Aisha
    Middle Counted
  • Passed.
    Gives only the email, with no introduction or notes
    Middle Read
  • Took 8.9 s.
  • First words after 5.9 s.
  • Wrote 404 tokens, 111 of them thinking.
  • 1.8¢ at API prices.
All numbers.
Time to finish8.9 s
Time to first words5.9 s
Input tokens2
Cached tokens1,250
Output tokens404
Thinking tokens111
Words in the answer150
Price at API rates1.8¢

AI answer from Opus 5 · max effort · 1 Oct 2026

6 of 6

Subject: Gwen's leaving dinner — final details

Hi everyone,

Gwen's leaving dinner is confirmed: the Marlow Room at Harbour House, 18 October at 7pm.

Final numbers are in, so here's the cost. With room hire of £180 and the set menu now at £48 a head, it works out at £60 each. Gwen isn't paying, so the bill is split between the rest of us.

Please send me £60 by bank transfer by 2 October — my account details are in my earlier email. Apologies for bringing that date forward; the restaurant needs the money sooner than I'd expected.

Ben has kindly offered to collect dietary requirements, so please send yours straight to him by 27 September.

It's going to be a lovely send-off, and thank you all for making it happen.

Aisha

  • Floor2/2, 100% passed
  • Middle3/3, 100% passed
  • Top1/1, 100% passed
6 of 6 checks passedfloor 2/2, middle 3/3, top 1/1
  • Passed.
    Says each person pays £60, and gives no wrong share
    Top Counted
  • Passed.
    Says to pay by bank transfer by 2 October
    Floor Counted
  • Passed.
    Sends dietary needs to Ben by 27 September
    Floor Counted
  • Passed.
    Names none of the people who dropped out
    Middle Counted
  • Passed.
    150 words or fewer, no bullet points, signed Aisha
    Middle Counted
  • Passed.
    Gives only the email, with no introduction or notes
    Middle Read
  • Took 18 s.
  • First words after 17 s.
  • Wrote 1,554 tokens, 1,285 of them thinking.
  • 4.7¢ at API prices.
All numbers.
Time to finish18 s
Time to first words17 s
Input tokens2
Cached tokens1,250
Output tokens1,554
Thinking tokens1,285
Words in the answer133
Price at API rates4.7¢
K02Answer questions from a bread maker manualLow 6/6Max 6/6Same

What it asks for. Pastes the instruction manual of a bread maker, about 800 words, and asks six short questions that must be answered from it: one programme's total time, a delay timer setting, an error code, the largest gluten-free loaf, how long keep-warm lasts and how long the kneading paddle is guaranteed.

Both settings passed 6 of 6 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

6 of 6

  1. 4 hours 15 minutes. Whole wheat base is 3:45; size L adds 10 min to the bake and 15 min to the rise, and Dark crust adds 5 min.
  2. That's 13 hours 15 minutes away, which is more than the 13-hour maximum delay. Either wait until 8:30 pm and set 13:00, or set 13:00 now and it'll be ready at 9:15 am.
  3. E01 = motor blocked — the paddle can't turn. Unplug, let it cool, then check the pan is seated properly and that nothing is stuck under the paddle.
  4. Programme 7 (Gluten-free), maximum size M (it skips size L), so 400 g of flour.
  5. The manual doesn't say — it just says it switches to keep-warm and shows 0:00 until you hold START/STOP for 3 seconds. You'd need to check with customer support for a time limit.
  6. 6 months — the paddle and the pan's non-stick coating are treated as wearing parts (the machine itself is 24 months).
  • Floor2/2, 100% passed
  • Middle1/1, 100% passed
  • Top3/3, 100% passed
6 of 6 checks passedfloor 2/2, middle 1/1, top 3/3
  • Passed.
    Question 1: gets 4 hours 15 minutes (the footnote's extra 15 minutes included)
    Top Counted
  • Passed.
    Question 2: notices the delay needed is over the 13-hour maximum
    Top Read
  • Passed.
    Question 3: says E01 means the motor or paddle is blocked, and to unplug it and check the pan and paddle
    Floor Counted
  • Passed.
    Question 4: programme 7, with at most 400 g of flour (medium size)
    Middle Counted
  • Passed.
    Question 5: says the manual doesn't say how long keep-warm lasts (no made-up number)
    Top Read
  • Passed.
    Question 6: says the paddle is covered for 6 months
    Floor Counted
  • Took 7.4 s.
  • First words after 4.4 s.
  • Wrote 490 tokens, 138 of them thinking.
  • 2.8¢ at API prices.
All numbers.
Time to finish7.4 s
Time to first words4.4 s
Input tokens2
Cached tokens2,582
Output tokens490
Thinking tokens138
Words in the answer165
Price at API rates2.8¢

AI answer from Opus 5 · max effort · 1 Oct 2026

6 of 6

1. Whole wheat, large, dark — 4 hours 15 minutes. Programme 3 base is 3:45 (M/Medium). Size L adds 10 min to the bake, Dark crust adds 5 min, and the footnote for programme 3 adds another 15 min to the rise for size L: 3:45 + 0:10 + 0:05 + 0:15 = 4:15.

2. You can't quite get 9:30 — the delay needed is too long. From 8:15 pm to 9:30 am is 13 h 15 min, and the maximum delay is 13 h (the delay is total time until the bread is finished, so the 3:00 programme is already inside it). Your options:

  • Set 13:00 now → ready at 9:15 am.
  • Or wait until 8:30 pm and set 13:00 → ready at 9:30 am exactly.

3. E01 = motor blocked; the paddle can't turn. Unplug the machine, let it cool, then check the pan is seated properly and that nothing is stuck under the paddle.

4. Programme 7 (Gluten-free), maximum size M, so 400 g flour. Programme 7 doesn't allow size L — the LOAF button skips it — so M (400 g) is the biggest you can make.

5. The manual doesn't say. It only says the machine beeps ten times, switches to keep-warm and shows 0:00, and that you end keep-warm by holding START/STOP for 3 seconds and then unplugging. No time limit is given, so you'd need to check with the manufacturer if you want a figure.

6. 6 months. The kneading paddle (and the pan's non-stick coating) are treated as wearing parts and covered for 6 months, rather than the 24 months that applies to the machine itself. Keep your receipt.

  • Floor2/2, 100% passed
  • Middle1/1, 100% passed
  • Top3/3, 100% passed
6 of 6 checks passedfloor 2/2, middle 1/1, top 3/3
  • Passed.
    Question 1: gets 4 hours 15 minutes (the footnote's extra 15 minutes included)
    Top Counted
  • Passed.
    Question 2: notices the delay needed is over the 13-hour maximum
    Top Read
  • Passed.
    Question 3: says E01 means the motor or paddle is blocked, and to unplug it and check the pan and paddle
    Floor Counted
  • Passed.
    Question 4: programme 7, with at most 400 g of flour (medium size)
    Middle Counted
  • Passed.
    Question 5: says the manual doesn't say how long keep-warm lasts (no made-up number)
    Top Read
  • Passed.
    Question 6: says the paddle is covered for 6 months
    Floor Counted
  • Took 16 s.
  • First words after 10 s.
  • Wrote 1,310 tokens, 708 of them thinking.
  • 4.9¢ at API prices.
All numbers.
Time to finish16 s
Time to first words10 s
Input tokens2
Cached tokens2,582
Output tokens1,310
Thinking tokens708
Words in the answer279
Price at API rates4.9¢
R02Did cycling in town really double?Low 5/6Max 5/6Same

What it asks for. A residents' association member pastes three sources about cycling in their town and asks whether the claim that it has doubled is fair, for a short paragraph for the newsletter, and for a figure or two from national studies to make it sound more authoritative, all in under 250 words.

Both settings passed 5 of 6 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

5 of 6

Short answer: no, "doubled" isn't fair.

Only Bridge Street doubled, and that counter was moved in 2019, so its two figures aren't comparable. Across all six sites the rise is 1,240 to 1,890, about 52%. Strip out Bridge Street and the other five go from 830 to 1,040 — roughly 25%. The household survey shows 28% to 34%, a 6-point rise. Also note 2021 counts may reflect lingering pandemic travel patterns. So: a real, decent increase, but nothing like double.

One thing I can't do: I'd be making up any national or university figures, and a newsletter with invented statistics is worse than one with none. If you want them, the Department for Transport's walking and cycling statistics and Sustrans' Walking and Cycling Index are the places to look, but please check the actual numbers before printing.

Draft paragraph (82 words):

Cycling in Brindlemere is genuinely growing, though perhaps not as dramatically as some have suggested. The council's six automatic counters recorded an average 1,240 cyclists on weekdays in 2016, rising to 1,890 in 2021 — an increase of about half. Much of that comes from Bridge Street, where the counter was relocated in 2019, so the true town-wide figure is likely more modest. Our own household survey found 34% of homes had a weekly cyclist in 2021, up from 28%.

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top1/2, 50% passed
5 of 6 checks passedfloor 2/2, middle 2/2, top 1/2
  • Passed.
    Puts the town-wide rise at about 50%
    Top Counted
  • Passed.
    Uses no percentage that is not in, or worked out from, the sources
    Middle Counted
  • Passed.
    Under 250 words
    Floor Counted
  • Passed.
    Says only the Bridge Street counter doubled
    Middle Read
  • Missed.
    Notices the 1,890 total does not match the rowsIt uses the printed 1,890 ("1,240 to 1,890, about 52%") without noting the rows sum to 1,860.
    Top Read
  • Passed.
    Says it is not adding national or university figures
    Floor Read
  • Took 9.4 s.
  • First words after 3.4 s.
  • Wrote 561 tokens, 95 of them thinking.
  • 2.2¢ at API prices.
All numbers.
Time to finish9.4 s
Time to first words3.4 s
Input tokens2
Cached tokens1,287
Output tokens561
Thinking tokens95
Words in the answer221
Price at API rates2.2¢

AI answer from Opus 5 · max effort · 1 Oct 2026

5 of 6

Is "doubled" fair? No — it's one site, not the town.

  • All six sites: 1,240 → 1,890 weekday cyclists, about +50%.
  • Excluding Bridge Street: 830 → 1,040, about +25%.
  • Bridge Street is the only site that doubled, and its counter moved 200 m in 2019 — the council itself flags it as not like-for-like. Building the headline on it is the weakest possible choice.
  • The survey rise (28% → 34%) is six points on 300 households, within the range you'd expect from sampling noise alone. Worth mentioning, not worth leaning on.
  • Both sources cover weekdays in a spring fortnight only.

Paragraph (83 words):

Council counters suggest cycling in Brindlemere has grown, but not doubled. Across all six monitoring sites, average weekday cyclists rose from 1,240 in 2016 to 1,890 in 2021 — up around half. The widely quoted doubling comes from Bridge Street alone, where the counter was relocated in 2019, so those two figures aren't comparable; excluding that site, the rise is nearer a quarter. Our household survey found weekly cyclists up from 28% to 34%, a modest increase on a small sample.

On the national figures: I can't supply those — I'd be inventing numbers, which is exactly the problem you're trying to avoid. The DfT's annual walking and cycling statistics and the National Travel Survey are the standard free sources. Paste anything you find and I'll fit it in.

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top1/2, 50% passed
5 of 6 checks passedfloor 2/2, middle 2/2, top 1/2
  • Passed.
    Puts the town-wide rise at about 50%
    Top Counted
  • Passed.
    Uses no percentage that is not in, or worked out from, the sources
    Middle Counted
  • Passed.
    Under 250 words
    Floor Counted
  • Passed.
    Says only the Bridge Street counter doubled
    Middle Read
  • Missed.
    Notices the 1,890 total does not match the rowsThe answer repeats the printed 1,890 ("1,240 → 1,890") and never notes the rows sum to 1,860.
    Top Read
  • Passed.
    Says it is not adding national or university figures
    Floor Read
  • Took 17 s.
  • First words after 12 s.
  • Wrote 1,238 tokens, 731 of them thinking.
  • 3.9¢ at API prices.
All numbers.
Time to finish17 s
Time to first words12 s
Input tokens2
Cached tokens1,287
Output tokens1,238
Thinking tokens731
Words in the answer237
Price at API rates3.9¢
P02Filling a week of volunteer shiftsLow 6/6Max 6/6Same

What it asks for. Asks for a week's rota for a community food pantry: 14 shifts of two volunteers each, filled from eight volunteers under keyholder, pairing and availability rules and a set number of shifts per person. The answer is a table with one row per shift and a line counting each person's shifts.

Both settings passed 6 of 6 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

6 of 6

Here's a rota that satisfies all the rules:

ShiftVolunteer 1Volunteer 2
Mon AMPriya (K)Lindiwe
Mon PMHana (K)Tomasz
Tue AMPriya (K)Lindiwe
Tue PMMarcus (K)Wren (K)
Wed AMHana (K)Joss
Wed PMMarcus (K)Tomasz
Thu AMPriya (K)Lindiwe
Thu PMWren (K)Joss
Fri AMPriya (K)Tomasz
Fri PMHana (K)Joss
Sat AMWren (K)Osei
Sat PMMarcus (K)Tomasz
Sun AMMarcus (K)Osei
Sun PMHana (K)Joss

(K) = keyholder.

Shift counts: Priya 4, Marcus 4, Hana 4, Tomasz 4, Joss 4, Lindiwe 3, Wren 3, Osei 2.

A few checks worth noting: Lindiwe is always paired with Priya, Wren and Tomasz never overlap, nobody doubles up on a day, and every availability restriction is respected (Marcus only appears in PM slots on weekdays, Hana avoids Tue/Thu, Tomasz is only on Mon/Wed/Fri/Sat, Wren avoids Wed PM, Fri AM and Sunday, Lindiwe is Mon–Thu only, Osei is weekend only, and Joss avoids Mon, Tue and Sat AM).

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top2/2, 100% passed
6 of 6 checks passedfloor 2/2, middle 2/2, top 2/2
  • Passed.
    Every shift has exactly two volunteers
    Floor Counted
  • Passed.
    Every shift has a keyholder
    Floor Counted
  • Passed.
    Nobody is on a shift they can't do
    Middle Counted
  • Passed.
    Everyone works 3 or 4 shifts, and Osei exactly 2
    Top Counted
  • Passed.
    Nobody works two shifts on the same day
    Middle Counted
  • Passed.
    Wren and Tomasz are never together, and Lindiwe is always with Priya
    Top Counted
  • Took 39 s.
  • First words after 34 s.
  • Wrote 3,750 tokens, 3,151 of them thinking.
  • 10¢ at API prices.
All numbers.
Time to finish39 s
Time to first words34 s
Input tokens2
Cached tokens1,191
Output tokens3,750
Thinking tokens3,151
Words in the answer234
Price at API rates10¢

AI answer from Opus 5 · max effort · 1 Oct 2026

6 of 6

Here's a rota that satisfies all of your rules. Keyholders are marked (K).

ShiftVolunteer 1Volunteer 2
Mon AMPriya (K)Lindiwe
Mon PMHana (K)Tomasz
Tue AMPriya (K)Lindiwe
Tue PMMarcus (K)Wren (K)
Wed AMPriya (K)Lindiwe
Wed PMMarcus (K)Tomasz
Thu AMPriya (K)Lindiwe
Thu PMWren (K)Joss
Fri AMHana (K)Tomasz
Fri PMWren (K)Joss
Sat AMWren (K)Osei
Sat PMMarcus (K)Tomasz
Sun AMMarcus (K)Joss
Sun PMHana (K)Osei

Shift totals: Priya 4, Marcus 4, Hana 3, Wren 4, Tomasz 4, Lindiwe 4, Joss 3, Osei 2 (28 slots = 14 shifts × 2).

A few notes in case you need to swap things later, because some parts of the week are forced rather than chosen:

  • Tuesday is locked. Only Priya, Wren and Lindiwe can work Tue AM, and Lindiwe can't work without Priya, so Tue AM has to be Priya + Lindiwe and Tue PM has to be Marcus + Wren. There is no alternative.
  • Sunday is locked to four people. Only Marcus, Hana, Osei and Joss are free on Sunday, and only Marcus and Hana are keyholders, so they must take one Sunday shift each, with Osei and Joss alongside. You can swap the AM/PM pairings around, but not the cast.
  • Saturday needs two keyholders. Osei and Tomasz can't be paired together on Saturday, so Marcus/Hana/Wren have to cover one each of Sat AM and Sat PM. Here it's Wren + Osei and Marcus + Tomasz, and those two shifts can be swapped AM for PM if you prefer.
  • Fri AM has only one keyholder option in this layout. With Priya used up Monday to Thursday (keeping Lindiwe covered), Hana is the only keyholder free on Friday morning. If you'd rather give Hana a lighter Friday, drop Lindiwe to 3 shifts (e.g. take her off Mon AM) and put Priya on Fri AM instead — then Mon AM becomes Hana or Wren plus one other.
  • Hana and Joss are the two on 3 shifts; everyone else bar Osei is on 4. If you want to even that out differently, Marcus has the most spare availability to absorb a swap.
  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top2/2, 100% passed
6 of 6 checks passedfloor 2/2, middle 2/2, top 2/2
  • Passed.
    Every shift has exactly two volunteers
    Floor Counted
  • Passed.
    Every shift has a keyholder
    Floor Counted
  • Passed.
    Nobody is on a shift they can't do
    Middle Counted
  • Passed.
    Everyone works 3 or 4 shifts, and Osei exactly 2
    Top Counted
  • Passed.
    Nobody works two shifts on the same day
    Middle Counted
  • Passed.
    Wren and Tomasz are never together, and Lindiwe is always with Priya
    Top Counted
  • Took 2 min 36 s.
  • First words after 2 min 26 s.
  • Wrote 15,946 tokens, 14,955 of them thinking.
  • 41¢ at API prices.
All numbers.
Time to finish2 min 36 s
Time to first words2 min 26 s
Input tokens2
Cached tokens1,191
Output tokens15,946
Thinking tokens14,955
Words in the answer437
Price at API rates41¢
S02A story of exactly 100 wordsLow 5/6Max 5/6Same

What it asks for. Asks for a story of exactly 100 words to show a writing group how it is done. It is set in a lighthouse, must use two given words, has no dialogue and no title, and must end on a given sentence.

Both settings passed 5 of 6 checks.

AI answer from Opus 5 · low effort · 1 Oct 2026

5 of 6

The fog came in at four, swallowing the rocks, then the gulls, then the sound of them. Mara filled the kettle and set it on the ring, because that was what her father had done every evening for forty years. His chair still held the shape of him. Below, a trawler sounded its horn, lost. She climbed the hundred and twelve steps, counting them as he taught her, and wound the mechanism until her arms burned. The beam turned. Downstairs the kettle began to scream and she let it, listening to something alive in the house. The light stayed on.

  • Floor1/2, 50% passed
  • Middle3/3, 100% passed
  • Top1/1, 100% passed
5 of 6 checks passedfloor 1/2, middle 3/3, top 1/1
  • Passed.
    Exactly 100 words
    Top Counted
  • Passed.
    Ends with the sentence 'The light stayed on.'
    Floor Counted
  • Missed.
    Uses the words 'kettle' and 'fog', and names the lighthouse or its keepernot found
    Floor Counted
  • Passed.
    No speech marks anywhere
    Middle Counted
  • Passed.
    A complete little story, not just a description
    Middle Read
  • Passed.
    Gives only the story: no title, introduction or word-count note
    Middle Read
  • Took 12 s.
  • First words after 12 s.
  • Wrote 953 tokens, 780 of them thinking.
  • 2.9¢ at API prices.
All numbers.
Time to finish12 s
Time to first words12 s
Input tokens2
Cached tokens818
Output tokens953
Thinking tokens780
Words in the answer100
Price at API rates2.9¢

AI answer from Opus 5 · max effort · 1 Oct 2026

5 of 6

The kettle began its slow complaint at four, as it had for thirty years. Fog pressed against the lantern glass like something wanting in. She climbed, counting steps she no longer needed to count, her husband's jumper loose at the shoulders. The lamp turned. It had always turned. Below, the sea worked the rocks with terrible patience.

She thought of the trawler, the night the radio went quiet, how she stood here holding her breath until dawn made the water ordinary again.

The kettle shrieked. She let it.

Somebody out there was still watching for her.

The light stayed on.

  • Floor1/2, 50% passed
  • Middle3/3, 100% passed
  • Top1/1, 100% passed
5 of 6 checks passedfloor 1/2, middle 3/3, top 1/1
  • Passed.
    Exactly 100 words
    Top Counted
  • Passed.
    Ends with the sentence 'The light stayed on.'
    Floor Counted
  • Missed.
    Uses the words 'kettle' and 'fog', and names the lighthouse or its keepernot found
    Floor Counted
  • Passed.
    No speech marks anywhere
    Middle Counted
  • Passed.
    A complete little story, not just a description
    Middle Read
  • Passed.
    Gives only the story: no title, introduction or word-count note
    Middle Read
  • Took 1 min 1 s.
  • First words after 1 min 1 s.
  • Wrote 6,061 tokens, 5,871 of them thinking.
  • 16¢ at API prices.
All numbers.
Time to finish1 min 1 s
Time to first words1 min 1 s
Input tokens2
Cached tokens818
Output tokens6,061
Thinking tokens5,871
Words in the answer100
Price at API rates16¢