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 Fable 5.1

Claude Fable 5.1

Released
Tested
Settings
Low effort and Max effort

Fable 5.1

24 prompts, every check counted

Low effort

95

  • Floor47/49, 96% passed
  • Middle55/58, 95% passed
  • Top62/65, 95% passed

Median answer 20 s, 11¢.

Max effort

72

  • Floor39/49, 80% passed
  • Middle42/58, 72% passed
  • Top45/65, 69% passed

Median answer 2 min 16 s, 62¢.

Max passed 38 fewer checks, cost 5.9 times as much and took 6.5 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.

Fable 5.1 answered all 24 prompts at both efforts on 2 October 2026, scoring 95 at low effort and 72 at max. At low effort it passed every hidden test on every code prompt; at max effort 5 answers ran out of room before they were finished.

Best at

  • Code at low effort: it passed every hidden test on every code prompt, including the query for customers who left and came back and the contacts import. F05 F06 F01 F07
  • Web pages at low effort: the dashboard from the mockup, the kanban board, the Minesweeper game and the garden page fixes passed every page test. B02 B03 B04 B05
  • Careful reading at both efforts: 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. I01 H02 K02
  • The volunteer rota, the text to the plumber and the story of an exact length met every check at both efforts. P02 W01 S02

Stumbled on

  • At max effort the dashboard, the kanban board and the Minesweeper game ran out of room before a page was written, so there was nothing for the page tests to load. B02 B03 B04
  • Also at max effort, the booking app plan ran out of room and met no check, and the query for customers who left and came back failed every hidden test, including the check that it runs. A01 F05
  • At low effort the column rename plan dropped the old column before old app versions had stopped using it, and the spec did not fully raise the question about seats at either effort. A03 A04
  • At low effort the cycling paragraph took the printed total on trust and ran over its word limit, and the microservices answer ran over length; at max effort that answer offered no modular monolith. R02 A02

What max effort changed

  • Overall it scored lower at max effort: 3 prompts gained at max effort, 6 lost ground and 15 stayed the same, for about 5.9 times the cost and 6.5 times the time.
  • Most of the loss came from answers that ran out of room. Thinking took 352,469 tokens across the max effort answers, against 14,247 at low effort, and the web pages group fell from 98 to 40. B02 B03 B04 A01 A03
  • Where it finished, max effort sometimes helped: the cycling paragraph went from 3 of 6 checks to 6 of 6, and the landing page from 8 of 9 to 9 of 9. R02 B01

In short

At low effort Fable 5.1 scored level with Fable 5 at low effort, with a median answer time of 20.3 seconds and a median cost of $0.107. Overall it scored lower at max effort, because 5 of its max effort answers 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
    98
  • Max effort
    40

Code

  • Low effort
    100
  • Max effort
    86

Planning and architecture

  • Low effort
    91
  • Max effort
    60

Everyday

  • Low effort
    92
  • Max effort
    100

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 8/9Max 9/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 more check than low.

AI answer from Fable 5.1 · low effort · 2 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 any browser.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Brindlebill – Simple invoicing for freelancers</title>
<meta name="description" content="Brindlebill is a simple invoicing app for freelancers. Send invoices in a minute, send gentle reminders, and get paid by card or bank.">
<style>
  :root {
    --ink: #1f2430;
    --muted: #5a6270;
    --brand: #2f6f5e;
    --brand-dark: #245548;
    --bg: #fbfaf7;
    --card: #ffffff;
    --line: #e4e2dc;
  }
  * { box-sizing: border-box; }
  html { scroll-behavior: smooth; }
  body {
    margin: 0;
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    font-size: 18px;
    line-height: 1.55;
    color: var(--ink);
    background: var(--bg);
  }
  h1, h2, h3 { line-height: 1.2; margin: 0 0 0.5rem; }
  h1 { font-size: clamp(2rem, 6vw, 3.25rem); }
  h2 { font-size: clamp(1.5rem, 4vw, 2.25rem); text-align: center; margin-bottom: 1.5rem; }
  h3 { font-size: 1.25rem; }
  p { margin: 0 0 1rem; }
  a { color: var(--brand); }

  .wrap { max-width: 1040px; margin: 0 auto; padding: 0 1.25rem; }
  section { padding: 3.5rem 0; }

  header.site {
    display: flex; align-items: center; justify-content: space-between;
    padding: 1rem 0;
  }
  .logo { font-weight: 700; font-size: 1.25rem; text-decoration: none; color: var(--ink); }
  nav a { margin-left: 1.25rem; text-decoration: none; color: var(--muted); }
  nav a:hover { color: var(--ink); }
  @media (max-width: 520px) { nav { display: none; } }

  .btn {
    display: inline-block;
    background: var(--brand);
    color: #fff;
    text-decoration: none;
    font-weight: 600;
    font-size: 1rem;
    padding: 0.85rem 1.5rem;
    border-radius: 8px;
    border: 0;
    cursor: pointer;
    min-height: 44px;
  }
  .btn:hover, .btn:focus-visible { background: var(--brand-dark); }
  .btn.outline { background: transparent; color: var(--brand); border: 2px solid var(--brand); }
  .btn.outline:hover { background: var(--brand); color: #fff; }

  .hero { text-align: center; padding: 4rem 0 3.5rem; }
  .hero p.pitch { font-size: 1.25rem; color: var(--muted); max-width: 36rem; margin: 0 auto 2rem; }
  .hero small { display: block; margin-top: 0.75rem; color: var(--muted); }

  .grid { display: grid; gap: 1.25rem; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
  .card {
    background: var(--card);
    border: 1px solid var(--line);
    border-radius: 12px;
    padding: 1.5rem;
  }
  .card p:last-child { margin-bottom: 0; color: var(--muted); }

  /* Pricing */
  .toggle {
    display: flex; justify-content: center; align-items: center; gap: 0.75rem;
    margin-bottom: 2rem; flex-wrap: wrap;
  }
  .switch {
    position: relative; width: 56px; height: 32px;
    background: #cfcbc2; border-radius: 999px; border: 0; cursor: pointer; padding: 0;
  }
  .switch::after {
    content: ""; position: absolute; top: 4px; left: 4px;
    width: 24px; height: 24px; background: #fff; border-radius: 50%;
    transition: transform 0.2s;
  }
  .switch[aria-checked="true"] { background: var(--brand); }
  .switch[aria-checked="true"]::after { transform: translateX(24px); }
  .switch:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
  .toggle .label { font-weight: 600; }
  .toggle .label.off { color: var(--muted); font-weight: 400; }
  .save {
    background: #e6f2ee; color: var(--brand-dark);
    font-size: 1rem; padding: 0.2rem 0.6rem; border-radius: 999px;
  }

  .plan { display: flex; flex-direction: column; }
  .plan.featured { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand); }
  .plan .price { font-size: 2.25rem; font-weight: 700; margin: 0.5rem 0 0; }
  .plan .per { color: var(--muted); font-size: 1rem; margin-bottom: 1rem; }
  .plan ul { padding-left: 1.2rem; margin: 0 0 1.5rem; flex: 1; }
  .plan li { margin-bottom: 0.4rem; }
  .plan .btn { text-align: center; }

  /* FAQ */
  .faq { max-width: 720px; margin: 0 auto; }
  .faq-item { border-bottom: 1px solid var(--line); }
  .faq-item h3 { margin: 0; font-size: 1.1rem; }
  .faq-q {
    width: 100%; background: none; border: 0; text-align: left;
    font: inherit; font-weight: 600; color: var(--ink);
    padding: 1rem 0; cursor: pointer;
    display: flex; justify-content: space-between; align-items: center; gap: 1rem;
  }
  .faq-q::after { content: "+"; font-size: 1.5rem; color: var(--brand); flex-shrink: 0; }
  .faq-q[aria-expanded="true"]::after { content: "–"; }
  .faq-q:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; }
  .faq-a { padding: 0 0 1rem; color: var(--muted); }
  .faq-a[hidden] { display: none; }

  .cta { text-align: center; }
  footer { text-align: center; padding: 2rem 1rem; color: var(--muted); font-size: 1rem; }
</style>
</head>
<body>

<div class="wrap">
  <header class="site">
    <a class="logo" href="#top">Brindlebill</a>
    <nav aria-label="Main">
      <a href="#features">Features</a>
      <a href="#pricing">Pricing</a>
      <a href="#faq">FAQ</a>
    </nav>
  </header>
</div>

<main id="top">

  <section class="hero">
    <div class="wrap">
      <h1>Invoicing that stays out of your way</h1>
      <p class="pitch">Brindlebill helps freelancers send invoices, nudge late payers, and get paid – without the busywork.</p>
      <a class="btn" href="#signup">Start free trial</a>
      <small>14 days free. No card needed.</small>
    </div>
  </section>

  <section id="features">
    <div class="wrap">
      <h2>Everything you need, nothing you don't</h2>
      <div class="grid">
        <article class="card">
          <h3>Invoices in a minute</h3>
          <p>Pick a client, add your line items, and send a clean PDF invoice before your coffee goes cold.</p>
        </article>
        <article class="card">
          <h3>Gentle reminders</h3>
          <p>Brindlebill follows up on overdue invoices with polite, automatic emails so you don't have to.</p>
        </article>
        <article class="card">
          <h3>Card and bank payments</h3>
          <p>Clients pay straight from the invoice by card or bank transfer, and you see the money land.</p>
        </article>
      </div>
    </div>
  </section>

  <section id="pricing">
    <div class="wrap">
      <h2>Simple pricing</h2>

      <div class="toggle">
        <span class="label" id="label-monthly">Monthly</span>
        <button class="switch" id="billing-switch" role="switch" aria-checked="false" aria-label="Bill yearly"></button>
        <span class="label off" id="label-yearly">Yearly</span>
        <span class="save">2 months free</span>
      </div>

      <div class="grid">
        <article class="card plan">
          <h3>Starter</h3>
          <p class="price" data-monthly="9" data-yearly="90">$9</p>
          <p class="per">per month</p>
          <ul>
            <li>5 clients</li>
            <li>PDF invoices</li>
          </ul>
          <a class="btn outline" href="#signup">Start free trial</a>
        </article>

        <article class="card plan featured">
          <h3>Pro</h3>
          <p class="price" data-monthly="19" data-yearly="190">$19</p>
          <p class="per">per month</p>
          <ul>
            <li>Unlimited clients</li>
            <li>Automatic reminders</li>
            <li>Your own logo</li>
          </ul>
          <a class="btn" href="#signup">Start free trial</a>
        </article>

        <article class="card plan">
          <h3>Team</h3>
          <p class="price" data-monthly="49" data-yearly="490">$49</p>
          <p class="per">per month</p>
          <ul>
            <li>Everything in Pro</li>
            <li>5 seats</li>
            <li>A shared client list</li>
          </ul>
          <a class="btn outline" href="#signup">Start free trial</a>
        </article>
      </div>
    </div>
  </section>

  <section id="faq">
    <div class="wrap">
      <h2>Frequently asked questions</h2>
      <div class="faq">

        <div class="faq-item">
          <h3><button class="faq-q" aria-expanded="false" aria-controls="faq-1">Can I cancel any time?</button></h3>
          <div class="faq-a" id="faq-1" hidden>
            <p>Yes. Cancel from your account page and you won't be charged again. Nothing is locked in.</p>
          </div>
        </div>

        <div class="faq-item">
          <h3><button class="faq-q" aria-expanded="false" aria-controls="faq-2">Do my clients need an account?</button></h3>
          <div class="faq-a" id="faq-2" hidden>
            <p>No. They open a link, see the invoice and pay. They never have to sign up.</p>
          </div>
        </div>

        <div class="faq-item">
          <h3><button class="faq-q" aria-expanded="false" aria-controls="faq-3">Which currencies can I invoice in?</button></h3>
          <div class="faq-a" id="faq-3" hidden>
            <p>You can invoice in 30 currencies, and each invoice shows the amount in your client's currency.</p>
          </div>
        </div>

        <div class="faq-item">
          <h3><button class="faq-q" aria-expanded="false" aria-controls="faq-4">What happens when the free trial ends?</button></h3>
          <div class="faq-a" id="faq-4" hidden>
            <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>
        </div>

      </div>
    </div>
  </section>

  <section id="signup" class="cta">
    <div class="wrap">
      <h2>Ready to get paid faster?</h2>
      <p>Try Brindlebill free for 14 days. No card needed.</p>
      <a class="btn" href="#signup">Start free trial</a>
    </div>
  </section>

</main>

<footer>
  &copy; <span id="year"></span> Brindlebill
</footer>

<script>
  // Monthly / Yearly pricing switch
  (function () {
    var sw = document.getElementById('billing-switch');
    var prices = document.querySelectorAll('.plan .price');
    var pers = document.querySelectorAll('.plan .per');
    var labelMonthly = document.getElementById('label-monthly');
    var labelYearly = document.getElementById('label-yearly');

    function render(yearly) {
      sw.setAttribute('aria-checked', yearly ? 'true' : 'false');
      labelMonthly.classList.toggle('off', yearly);
      labelYearly.classList.toggle('off', !yearly);
      for (var i = 0; i < prices.length; i++) {
        var amount = yearly ? prices[i].dataset.yearly : prices[i].dataset.monthly;
        prices[i].textContent = '$' + amount;
        pers[i].textContent = yearly ? 'per year' : 'per month';
      }
    }

    sw.addEventListener('click', function () {
      render(sw.getAttribute('aria-checked') !== 'true');
    });
    labelMonthly.addEventListener('click', function () { render(false); });
    labelYearly.addEventListener('click', function () { render(true); });
  })();

  // FAQ accordion
  (function () {
    var buttons = document.querySelectorAll('.faq-q');
    for (var i = 0; i < buttons.length; i++) {
      buttons[i].addEventListener('click', function () {
        var open = this.getAttribute('aria-expanded') === 'true';
        var panel = document.getElementById(this.getAttribute('aria-controls'));
        this.setAttribute('aria-expanded', open ? 'false' : 'true');
        panel.hidden = open;
      });
    }
  })();

  document.getElementById('year').textContent = new Date().getFullYear();
</script>

</body>
</html>

A few notes:

  • Yearly prices are the monthly price × 10 (two months free), shown as the full year: Starter $90, Pro $190, Team $490. The label switches between "per month" and "per year".
  • Font sizes: body text is 18px, and the smallest text on the page (footer, "per month", the "2 months free" badge) is 16px.
  • Headings: one h1 for the headline, h2 for each section, h3 for features, plans and FAQ questions.
  • Accessibility: the billing switch uses role="switch", FAQ questions are real buttons with aria-expanded, and all tap targets are at least 44px tall.
  • The "Start free trial" buttons currently link to the #signup section. Replace those hrefs with your real sign-up URL when you have one.
  • The 14-day trial and "no card needed" lines in the hero and sign-up section are my placeholders – edit them to match your actual trial terms.
  • 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 49 s.
  • First words after 2.7 s.
  • Wrote 5,226 tokens, 59 of them thinking.
  • 27¢ at API prices.
All numbers.
Time to finish49 s
Time to first words2.7 s
Input tokens1,273
Cached tokens0
Output tokens5,226
Thinking tokens59
Words in the answer1,361
Price at API rates27¢

AI answer from Fable 5.1 · max effort · 2 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. Everything is inline (styles, script, and three small inline SVG icons), with a system font stack and no external requests. The only thing to change is the sign-up link, which currently points at /signup.

<!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>
<meta name="description" content="Brindlebill is a small invoicing app for freelancers. Send an invoice in a minute, let gentle reminders chase late payers, and get paid by card or bank.">
<style>
  :root {
    --bg: #fbf8f2;
    --surface: #ffffff;
    --text: #1f1b16;
    --muted: #5a544b;
    --brand: #14604f;
    --brand-dark: #0e4a3d;
    --brand-soft: #e6f0ec;
    --accent: #f3c35a;
    --accent-dark: #e9b23a;
    --line: #e6dfd3;
    --radius: 1rem;
  }

  *, *::before, *::after { box-sizing: border-box; }

  html {
    color-scheme: light;
    -webkit-text-size-adjust: 100%;
  }

  @media (prefers-reduced-motion: no-preference) {
    html { scroll-behavior: smooth; }
  }

  body {
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    font-size: 1rem;            /* 16px – nothing on the page goes below this */
    line-height: 1.6;
    color: var(--text);
    background: var(--bg);
  }

  h1, h2, h3 { margin: 0 0 0.6rem; line-height: 1.15; letter-spacing: -0.01em; }
  h1 { font-size: clamp(2.125rem, 5vw + 1rem, 3.5rem); }
  h2 { font-size: clamp(1.75rem, 2.5vw + 1rem, 2.5rem); }
  h3 { font-size: 1.25rem; }
  p  { margin: 0 0 1rem; }
  ul { margin: 0; padding: 0; }
  a  { color: var(--brand); }
  svg { display: block; }

  .container {
    max-width: 68rem;
    margin: 0 auto;
    padding: 0 1.25rem;
  }

  /* Hidden from view, still available to assistive tech */
  .visually-hidden,
  .segmented input {
    position: absolute !important;
    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;
  }

  .skip-link {
    position: absolute;
    top: -10rem;
    left: 1rem;
    z-index: 10;
    padding: 0.75rem 1rem;
    border-radius: 0.5rem;
    background: var(--text);
    color: #fff;
  }
  .skip-link:focus { top: 1rem; }

  /* Buttons */
  .button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 3.25rem;
    padding: 0.75rem 1.75rem;
    border: 0;
    border-radius: 999px;
    background: var(--brand);
    color: #fff;
    font-size: 1.0625rem;
    font-weight: 600;
    text-decoration: none;
    cursor: pointer;
    transition: background 0.15s ease;
  }
  .button:hover { background: var(--brand-dark); }
  .button:focus-visible { outline: 3px solid var(--brand); outline-offset: 3px; }

  .button-light { background: var(--accent); color: var(--text); }
  .button-light:hover { background: var(--accent-dark); }
  .button-light:focus-visible { outline-color: #fff; }

  /* Header */
  .site-header { padding: 1rem 0; }
  .site-header .container {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem 1.5rem;
  }
  .brand {
    display: inline-flex;
    align-items: center;
    gap: 0.6rem;
    font-size: 1.25rem;
    font-weight: 700;
    color: var(--text);
    text-decoration: none;
  }
  .brand-mark {
    width: 1.75rem;
    height: 1.75rem;
    border-radius: 0.5rem;
    background: linear-gradient(135deg, var(--brand) 55%, var(--accent));
  }
  .site-nav ul { display: flex; gap: 1.25rem; list-style: none; }
  .site-nav a {
    display: inline-block;
    padding: 0.6rem 0.1rem;
    color: var(--text);
    text-decoration: none;
  }
  .site-nav a:hover { text-decoration: underline; }
  .site-nav a:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; border-radius: 0.25rem; }

  /* Hero */
  .hero { padding: clamp(2rem, 6vw, 5rem) 0 clamp(3rem, 7vw, 5rem); }
  .hero .container { display: grid; gap: 2.5rem; align-items: center; }
  @media (min-width: 54rem) {
    .hero .container { grid-template-columns: 1.1fr 0.9fr; gap: 3rem; }
  }
  .pitch {
    max-width: 36ch;
    margin-bottom: 1.75rem;
    font-size: 1.25rem;
    color: var(--muted);
  }
  .hero-note { margin: 1rem 0 0; color: var(--muted); }

  /* Decorative sample invoice (CSS only) */
  .mock {
    max-width: 26rem;
    margin: 0 auto;
    padding: 1.5rem;
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    box-shadow: 0 24px 48px -28px rgba(31, 27, 22, 0.4);
  }
  .mock-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    margin-bottom: 1rem;
    font-weight: 600;
  }
  .mock-badge {
    padding: 0.2rem 0.75rem;
    border-radius: 999px;
    background: #e3f3ea;
    color: #0d5a3f;
  }
  .mock-row {
    display: flex;
    justify-content: space-between;
    gap: 1rem;
    padding: 0.6rem 0;
    border-top: 1px solid var(--line);
    color: var(--muted);
  }
  .mock-total {
    display: flex;
    justify-content: space-between;
    gap: 1rem;
    margin-top: 0.25rem;
    padding-top: 0.75rem;
    border-top: 2px solid var(--text);
    font-size: 1.125rem;
    font-weight: 700;
  }
  .mock-foot { margin: 1rem 0 0; color: var(--muted); }

  /* Sections */
  .section { padding: clamp(3rem, 8vw, 5rem) 0; }
  .section[id] { scroll-margin-top: 1rem; }
  .section-intro {
    max-width: 44ch;
    margin-bottom: 0;
    font-size: 1.125rem;
    color: var(--muted);
  }

  /* Features */
  .features {
    display: grid;
    gap: 1.5rem;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
    margin-top: 2.5rem;
    list-style: none;
  }
  .feature {
    padding: 1.75rem 1.5rem;
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--radius);
  }
  .feature-icon {
    display: grid;
    place-items: center;
    width: 3rem;
    height: 3rem;
    margin-bottom: 1.25rem;
    border-radius: 0.85rem;
    background: var(--brand-soft);
    color: var(--brand);
  }
  .feature-icon svg { width: 1.5rem; height: 1.5rem; }
  .feature p { margin: 0; color: var(--muted); }

  /* Pricing */
  .pricing {
    background: var(--surface);
    border-top: 1px solid var(--line);
    border-bottom: 1px solid var(--line);
  }
  .billing { margin: 2rem 0 0; padding: 0; border: 0; }
  .segmented {
    position: relative;
    display: inline-flex;
    padding: 0.25rem;
    border-radius: 999px;
    background: #efe9de;
  }
  .segmented label {
    display: inline-flex;
    align-items: center;
    min-height: 2.75rem;
    padding: 0.5rem 1.5rem;
    border-radius: 999px;
    font-weight: 600;
    cursor: pointer;
    transition: background 0.15s ease, color 0.15s ease;
  }
  .segmented label:hover { color: var(--brand-dark); }
  .segmented input:checked + label { background: var(--brand); color: #fff; }
  .segmented input:focus-visible + label { outline: 3px solid var(--brand); outline-offset: 2px; }
  .billing-note { margin: 0.75rem 0 0; color: var(--muted); }

  .plans {
    display: grid;
    gap: 1.5rem;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
    margin-top: 2rem;
    list-style: none;
  }
  .plan {
    display: flex;
    flex-direction: column;
    padding: 1.75rem 1.5rem;
    background: var(--bg);
    border: 1px solid var(--line);
    border-radius: var(--radius);
  }
  .plan h3 { margin-bottom: 0.25rem; }
  .price {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.5rem;
    margin: 0.5rem 0 1.25rem;
  }
  .amount {
    font-size: 2.5rem;
    font-weight: 700;
    line-height: 1;
    letter-spacing: -0.02em;
  }
  .period { color: var(--muted); }
  .plan ul { list-style: none; }
  .plan li { position: relative; margin: 0.6rem 0; padding-left: 1.75rem; }
  .plan li::before {
    content: "";
    position: absolute;
    top: 0.3em;
    left: 0.4rem;
    width: 0.4rem;
    height: 0.75rem;
    border-right: 2px solid var(--brand);
    border-bottom: 2px solid var(--brand);
    transform: rotate(45deg);
  }

  /* FAQ */
  .faq-list { margin-top: 2rem; border-top: 1px solid var(--line); }
  .faq-item { border-bottom: 1px solid var(--line); }
  .faq-item summary {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    padding: 1.1rem 0;
    list-style: none;
    cursor: pointer;
  }
  .faq-item summary::-webkit-details-marker { display: none; }
  .faq-item summary:focus-visible { outline: 3px solid var(--brand); outline-offset: 2px; border-radius: 0.25rem; }
  .faq-item h3 { margin: 0; font-size: 1.125rem; font-weight: 600; line-height: 1.4; }
  .faq-item summary::after {
    content: "";
    flex: none;
    width: 0.65rem;
    height: 0.65rem;
    margin-right: 0.4rem;
    border-right: 2px solid currentColor;
    border-bottom: 2px solid currentColor;
    transform: translateY(-25%) rotate(45deg);
  }
  .faq-item[open] summary::after { transform: translateY(25%) rotate(-135deg); }
  @media (prefers-reduced-motion: no-preference) {
    .faq-item summary::after { transition: transform 0.2s ease; }
  }
  .faq-item p {
    max-width: 62ch;
    margin: 0 0 1.4rem;
    padding-right: 2rem;
    color: var(--muted);
  }

  /* Closing call to action */
  .cta { padding: clamp(3rem, 8vw, 5rem) 0; }
  .cta-box {
    padding: clamp(2.5rem, 6vw, 4rem) 1.5rem;
    border-radius: 1.5rem;
    background: var(--brand);
    color: #fff;
    text-align: center;
  }
  .cta-box h2 { color: #fff; }
  .cta-box p {
    max-width: 40ch;
    margin: 0 auto 1.75rem;
    font-size: 1.125rem;
    color: rgba(255, 255, 255, 0.88);
  }

  /* Footer */
  .site-footer { padding: 0 0 2.5rem; color: var(--muted); }
  .site-footer .container {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    gap: 0.25rem 1.5rem;
  }
  .site-footer p { margin: 0; }
</style>
</head>
<body>
  <a class="skip-link" href="#main">Skip to content</a>

  <header class="site-header">
    <div class="container">
      <a class="brand" href="#top">
        <span class="brand-mark" aria-hidden="true"></span>
        Brindlebill
      </a>
      <nav class="site-nav" aria-label="Page sections">
        <ul>
          <li><a href="#features">Features</a></li>
          <li><a href="#pricing">Pricing</a></li>
          <li><a href="#faq">FAQ</a></li>
        </ul>
      </nav>
    </div>
  </header>

  <main id="main">

    <!-- Hero -->
    <section class="hero" aria-labelledby="hero-heading">
      <div class="container">
        <div>
          <h1 id="hero-heading">Invoicing for freelancers who'd rather be working.</h1>
          <p class="pitch">Brindlebill writes the invoice, sends the polite reminder and takes the payment, so you can get on with the job.</p>
          <!-- Point this at your sign-up page -->
          <a class="button" href="/signup">Start free trial</a>
          <p class="hero-note">Free trial on every plan.</p>
        </div>

        <div class="mock" aria-hidden="true">
          <div class="mock-head">
            <span>Invoice #0042</span>
            <span class="mock-badge">Paid</span>
          </div>
          <div class="mock-row"><span>Brand refresh, 3 days</span><span>$1,350</span></div>
          <div class="mock-row"><span>Logo files</span><span>$250</span></div>
          <div class="mock-total"><span>Total</span><span>$1,600</span></div>
          <p class="mock-foot">Paid by card, two days early.</p>
        </div>
      </div>
    </section>

    <!-- Features -->
    <section id="features" class="section" aria-labelledby="features-heading">
      <div class="container">
        <h2 id="features-heading">Everything you need to get paid</h2>
        <p class="section-intro">Brindlebill handles the fiddly parts of billing so your evenings stay yours.</p>

        <ul class="features">
          <li class="feature">
            <div class="feature-icon">
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
                <circle cx="12" cy="12" r="9"/>
                <path d="M12 7v5l3.5 2"/>
              </svg>
            </div>
            <h3>Invoices in a minute</h3>
            <p>Pick a client, add your line items and send; Brindlebill remembers your rates and terms so the next one is even quicker.</p>
          </li>

          <li class="feature">
            <div class="feature-icon">
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
                <path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.5 1.5H4.5z"/>
                <path d="M10 20a2 2 0 0 0 4 0"/>
              </svg>
            </div>
            <h3>Gentle reminders</h3>
            <p>When an invoice runs late, Brindlebill sends a polite nudge on your behalf, so you never have to write the awkward email.</p>
          </li>

          <li class="feature">
            <div class="feature-icon">
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
                <rect x="3" y="6" width="18" height="12" rx="2"/>
                <path d="M3 10.5h18"/>
                <path d="M7 14.5h4"/>
              </svg>
            </div>
            <h3>Card and bank payments</h3>
            <p>Clients pay straight from the invoice by card or bank transfer, and the payment is matched to the invoice the moment it arrives.</p>
          </li>
        </ul>
      </div>
    </section>

    <!-- Pricing -->
    <section id="pricing" class="section pricing" aria-labelledby="pricing-heading">
      <div class="container">
        <h2 id="pricing-heading">Simple pricing</h2>
        <p class="section-intro">Every plan starts with a free trial.</p>

        <fieldset class="billing">
          <legend class="visually-hidden">Billing period</legend>
          <div class="segmented">
            <input type="radio" name="billing" id="billing-monthly" value="monthly" checked>
            <label for="billing-monthly">Monthly</label>
            <input type="radio" name="billing" id="billing-yearly" value="yearly">
            <label for="billing-yearly">Yearly</label>
          </div>
          <p id="billing-note" class="billing-note" aria-live="polite">Pay yearly and get two months free.</p>
        </fieldset>

        <ul class="plans">
          <li class="plan">
            <h3>Starter</h3>
            <p class="price">
              <span class="amount" data-monthly="$9" data-yearly="$90">$9</span>
              <span class="period">per month</span>
            </p>
            <ul>
              <li>5 clients</li>
              <li>PDF invoices</li>
            </ul>
          </li>

          <li class="plan">
            <h3>Pro</h3>
            <p class="price">
              <span class="amount" data-monthly="$19" data-yearly="$190">$19</span>
              <span class="period">per month</span>
            </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 class="amount" data-monthly="$49" data-yearly="$490">$49</span>
              <span class="period">per month</span>
            </p>
            <ul>
              <li>Everything in Pro</li>
              <li>5 seats</li>
              <li>A shared client list</li>
            </ul>
          </li>
        </ul>
      </div>
    </section>

    <!-- FAQ -->
    <section id="faq" class="section" aria-labelledby="faq-heading">
      <div class="container">
        <h2 id="faq-heading">Common questions</h2>

        <div class="faq-list">
          <details class="faq-item">
            <summary><h3>Can I cancel any time?</h3></summary>
            <p>Yes. Cancel from your account page and you won't be charged again. Nothing is locked in.</p>
          </details>

          <details class="faq-item">
            <summary><h3>Do my clients need an account?</h3></summary>
            <p>No. They open a link, see the invoice and pay. They never have to sign up.</p>
          </details>

          <details class="faq-item">
            <summary><h3>Which currencies can I invoice in?</h3></summary>
            <p>You can invoice in 30 currencies, and each invoice shows the amount in your client's currency.</p>
          </details>

          <details class="faq-item">
            <summary><h3>What happens when the free trial ends?</h3></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>
      </div>
    </section>

    <!-- Closing call to action -->
    <section class="cta" aria-labelledby="cta-heading">
      <div class="container">
        <div class="cta-box">
          <h2 id="cta-heading">Send your first invoice today</h2>
          <p>Start your free trial and have an invoice out the door in a minute.</p>
          <!-- Point this at your sign-up page -->
          <a class="button button-light" href="/signup">Start free trial</a>
        </div>
      </div>
    </section>

  </main>

  <footer class="site-footer">
    <div class="container">
      <p>Brindlebill</p>
      <p>Invoicing for freelancers.</p>
    </div>
  </footer>

  <script>
    (function () {
      var monthly = document.getElementById('billing-monthly');
      var yearly  = document.getElementById('billing-yearly');
      var note    = document.getElementById('billing-note');
      var amounts = document.querySelectorAll('.amount');
      var periods = document.querySelectorAll('.period');

      if (!monthly || !yearly) { return; }

      function render() {
        var isYearly = yearly.checked;
        var key = isYearly ? 'yearly' : 'monthly';

        amounts.forEach(function (el) {
          el.textContent = el.getAttribute('data-' + key);
        });

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

        note.textContent = isYearly
          ? 'Billed once a year. That is two months free compared with paying monthly.'
          : 'Pay yearly and get two months free.';
      }

      monthly.addEventListener('change', render);
      yearly.addEventListener('change', render);

      // Run once on load in case the browser restored a previous selection.
      render();
    })();
  </script>
</body>
</html>

A few notes on how it's put together:

  • Yearly prices are 10× monthly (12 months minus the two free), shown as the full-year figure: $90, $190 and $490 per year. The switch is a pair of radio buttons styled as a segmented control, so it works with keyboard and screen readers, and the note underneath is a live region that announces the change.
  • FAQ uses native <details>/<summary>, so each question opens and closes on its own and still works if JavaScript is blocked. Your answer text is used verbatim.
  • Headings run h1 (headline) → h2 for each section → h3 for feature titles, plan names and FAQ questions.
  • Text size is 16px at the smallest; small print like the billing note and footer stays at 16px too. Tap targets (nav links, switch, FAQ rows, buttons) are at least 44px tall.
  • The sample invoice in the hero is just styled HTML and is hidden from assistive tech as decoration, so there are no images anywhere.
  • 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 6 min 11 s.
  • First words after 4 min 55 s.
  • Wrote 30,734 tokens, 22,408 of them thinking.
  • $1.55 at API prices.
All numbers.
Time to finish6 min 11 s
Time to first words4 min 55 s
Input tokens1,273
Cached tokens0
Output tokens30,734
Thinking tokens22,408
Words in the answer2,084
Price at API rates$1.55
B02Build a dashboard from a picture of its designLow 7/7Max 0/7Max −7

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 7 fewer checks than low.

AI answer from Fable 5.1 · low effort · 2 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 admin screen as a single self-contained HTML file. No libraries, no external fonts or images; the chart is plain HTML/CSS with bar heights computed from the numbers (each bar is calc(100% * n / 31), so they're exactly to scale against the tallest, 31).

<!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>
  * { box-sizing: border-box; margin: 0; padding: 0; }

  :root {
    --navy: #1f2a44;
    --blue: #3b74f0;
    --bg: #f4f6fa;
    --border: #e4e8ef;
    --text: #1c2333;
    --muted: #6b7280;
    --green: #1d8a44;
    --red: #d93025;
    --sidebar-w: 240px;
  }

  html, body { overflow-x: hidden; }

  body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    color: var(--text);
    background: var(--bg);
    min-height: 100vh;
    display: flex;
  }

  /* ---------- Sidebar ---------- */
  .sidebar {
    width: var(--sidebar-w);
    background: var(--navy);
    color: #fff;
    flex-shrink: 0;
    display: flex;
    flex-direction: column;
    min-height: 100vh;
  }
  .brand {
    height: 64px;
    display: flex;
    align-items: center;
    padding: 0 28px;
    font-weight: 700;
    font-size: 16px;
    border-bottom: 1px solid rgba(255,255,255,0.1);
  }
  .nav { list-style: none; padding: 24px 12px; }
  .nav li a {
    display: flex;
    align-items: center;
    gap: 14px;
    padding: 0 16px;
    height: 40px;
    border-radius: 7px;
    color: #c9d1e0;
    text-decoration: none;
    font-size: 15px;
    margin-bottom: 4px;
  }
  .nav li a:hover { background: rgba(255,255,255,0.06); color: #fff; }
  .nav li a.active { background: var(--blue); color: #fff; }
  .nav .icon {
    width: 16px;
    height: 16px;
    border: 2px solid currentColor;
    border-radius: 3px;
    flex-shrink: 0;
  }

  /* ---------- Main ---------- */
  .main { flex: 1; min-width: 0; display: flex; flex-direction: column; }

  .topbar {
    height: 64px;
    background: #fff;
    border-bottom: 1px solid var(--border);
    display: flex;
    align-items: center;
    padding: 0 32px;
    gap: 16px;
  }
  .topbar h1 { font-size: 22px; font-weight: 700; flex: 1; }
  .menu-btn {
    display: none;
    width: 40px; height: 40px;
    background: none;
    border: 1px solid var(--border);
    border-radius: 7px;
    cursor: pointer;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    gap: 5px;
  }
  .menu-btn span { width: 18px; height: 2px; background: var(--text); border-radius: 1px; }

  .search {
    display: flex;
    align-items: center;
    gap: 10px;
    width: 292px;
    height: 36px;
    background: #f1f3f7;
    border: 1px solid var(--border);
    border-radius: 7px;
    padding: 0 12px;
  }
  .search svg { width: 16px; height: 16px; flex-shrink: 0; }
  .search input {
    border: none;
    background: transparent;
    outline: none;
    font-size: 15px;
    width: 100%;
    color: var(--text);
    font-family: inherit;
  }
  .search input::placeholder { color: #8a93a3; }
  .avatar {
    width: 36px; height: 36px;
    border-radius: 50%;
    background: var(--navy);
    color: #fff;
    font-size: 13px;
    font-weight: 700;
    display: flex;
    align-items: center;
    justify-content: center;
    margin-left: 44px;
  }

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

  .card {
    background: #fff;
    border: 1px solid var(--border);
    border-radius: 8px;
  }

  /* ---------- Stats ---------- */
  .stats {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 32px;
  }
  .stat { padding: 18px 20px; }
  .stat .label { font-size: 14px; color: var(--muted); font-weight: 500; margin-bottom: 4px; }
  .stat .value { font-size: 32px; font-weight: 700; line-height: 1.2; margin-bottom: 8px; }
  .stat .delta { font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
  .stat .delta.up { color: var(--green); }
  .stat .delta.down { color: var(--red); }
  .delta .tri { width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; }
  .up .tri { border-bottom: 7px solid var(--green); }
  .down .tri { border-top: 7px solid var(--red); }

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

  .bars {
    position: relative;
    height: 130px;
    display: flex;
    justify-content: space-around;
    align-items: flex-end;
    border-bottom: 1px solid var(--border);
    background:
      linear-gradient(to bottom, var(--border) 1px, transparent 1px) 0 0 / 100% 25% repeat-y;
  }
  .bar {
    position: relative;
    width: 55px;
    background: var(--blue);
    border-radius: 3px 3px 0 0;
  }
  .bar b {
    position: absolute;
    bottom: 100%;
    left: 50%;
    transform: translateX(-50%);
    margin-bottom: 6px;
    font-size: 13px;
    font-weight: 700;
  }
  .labels { display: flex; justify-content: space-around; margin-top: 10px; }
  .labels span { width: 55px; text-align: center; font-size: 13px; color: var(--muted); }

  /* ---------- Table ---------- */
  .jobs h2 { font-size: 16px; font-weight: 700; padding: 18px 24px; }
  .table-wrap { overflow-x: auto; }
  table { width: 100%; border-collapse: collapse; font-size: 15px; }
  th {
    text-align: left;
    font-size: 13px;
    font-weight: 600;
    color: var(--muted);
    background: #f7f9fc;
    padding: 10px 24px;
    border-top: 1px solid var(--border);
    border-bottom: 1px solid var(--border);
    white-space: nowrap;
  }
  td { padding: 14px 24px; border-bottom: 1px solid var(--border); white-space: nowrap; }
  tr:last-child td { border-bottom: none; }
  td.job { font-weight: 700; }
  .badge {
    display: inline-block;
    padding: 4px 11px;
    border-radius: 999px;
    font-size: 13px;
    font-weight: 600;
  }
  .badge.ready   { background: #dcf5e6; color: #1a7a3f; }
  .badge.progress{ background: #fdf3cd; color: #8a6400; }
  .badge.waiting { background: #ebe7fb; color: #5b3fb0; }

  .overlay {
    display: none;
    position: fixed; inset: 0;
    background: rgba(0,0,0,0.45);
    z-index: 40;
  }

  /* ---------- Mobile ---------- */
  @media (max-width: 860px) {
    .sidebar {
      position: fixed;
      top: 0; left: 0; bottom: 0;
      z-index: 50;
      transform: translateX(-100%);
      transition: transform 0.25s ease;
    }
    body.menu-open .sidebar { transform: translateX(0); }
    body.menu-open .overlay { display: block; }

    .menu-btn { display: flex; }
    .topbar { padding: 0 16px; gap: 12px; flex-wrap: wrap; height: auto; min-height: 64px; }
    .topbar h1 { font-size: 20px; }
    .search { order: 10; width: 100%; margin-bottom: 12px; }
    .avatar { margin-left: 0; }

    .content { padding: 16px; gap: 16px; }
    .stats { grid-template-columns: 1fr; gap: 16px; }
    .chart { padding: 16px; }
    .bar { width: 28px; }
    .labels span { width: 28px; font-size: 12px; }
    th, td { padding-left: 16px; padding-right: 16px; }
  }
</style>
</head>
<body>

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

  <div class="main">
    <header class="topbar">
      <button class="menu-btn" id="menuBtn" aria-label="Open menu" aria-controls="sidebar" aria-expanded="false">
        <span></span><span></span><span></span>
      </button>
      <h1>Overview</h1>
      <label class="search">
        <svg viewBox="0 0 24 24" fill="none" stroke="#6b7280" stroke-width="2.2" stroke-linecap="round">
          <circle cx="10.5" cy="10.5" r="6.5"/><line x1="15.5" y1="15.5" x2="21" y2="21"/>
        </svg>
        <input type="search" placeholder="Search jobs and bikes">
      </label>
      <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="tri"></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="tri"></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="tri"></span>6.0% vs last week</div>
        </div>
      </section>

      <section class="card chart">
        <div class="chart-head">
          <h2>Completed per day</h2>
          <span>Last 7 days</span>
        </div>
        <div class="bars" role="img" aria-label="Jobs completed per day: Mon 14, Tue 22, Wed 18, Thu 27, Fri 31, Sat 12, Sun 6">
          <div class="bar" style="height: calc(100% * 14 / 31)"><b>14</b></div>
          <div class="bar" style="height: calc(100% * 22 / 31)"><b>22</b></div>
          <div class="bar" style="height: calc(100% * 18 / 31)"><b>18</b></div>
          <div class="bar" style="height: calc(100% * 27 / 31)"><b>27</b></div>
          <div class="bar" style="height: calc(100% * 31 / 31)"><b>31</b></div>
          <div class="bar" style="height: calc(100% * 12 / 31)"><b>12</b></div>
          <div class="bar" style="height: calc(100% * 6 / 31)"><b>6</b></div>
        </div>
        <div class="labels">
          <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">
        <h2>Recent jobs</h2>
        <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">#2041</td><td>Trail hardtail</td><td>Gear tune and chain</td><td>12 Mar</td><td><span class="badge ready">Ready for pickup</span></td></tr>
              <tr><td class="job">#2040</td><td>Commuter 700c</td><td>Brake pads, both wheels</td><td>12 Mar</td><td><span class="badge progress">In progress</span></td></tr>
              <tr><td class="job">#2039</td><td>Folding bike</td><td>New rear tyre</td><td>13 Mar</td><td><span class="badge waiting">Waiting for parts</span></td></tr>
              <tr><td class="job">#2038</td><td>Road racer</td><td>Full service</td><td>14 Mar</td><td><span class="badge progress">In progress</span></td></tr>
              <tr><td class="job">#2037</td><td>Cargo trike</td><td>Wheel true, front</td><td>14 Mar</td><td><span class="badge ready">Ready for pickup</span></td></tr>
            </tbody>
          </table>
        </div>
      </section>

    </main>
  </div>

<script>
  const body = document.body;
  const btn = document.getElementById('menuBtn');
  const overlay = document.getElementById('overlay');

  function setMenu(open) {
    body.classList.toggle('menu-open', open);
    btn.setAttribute('aria-expanded', open);
  }
  btn.addEventListener('click', () => setMenu(!body.classList.contains('menu-open')));
  overlay.addEventListener('click', () => setMenu(false));
  document.addEventListener('keydown', e => { if (e.key === 'Escape') setMenu(false); });
  document.querySelectorAll('.nav a').forEach(a => a.addEventListener('click', () => setMenu(false)));
</script>
</body>
</html>

A few notes on how it matches and behaves:

  • Desktop layout – fixed 240px navy sidebar, white top bar with the heading, search box and "TB" avatar, light grey content area with the three stat cards in a row, the chart card, then the Recent jobs table. Colours, radii, badge tints and the up/down triangle indicators follow the mockup.
  • Chart – the bar area has faint horizontal gridlines and a baseline; each bar's height is calc(100% * n / 31), so Fri (31) fills the full height and the others scale proportionally. The number sits above each bar and the day label below.
  • Phone (≤860px) – the sidebar slides off-canvas and is opened by a hamburger button in the top bar (closed by the overlay, Escape, or tapping a nav link). The search box drops onto its own row, the stat cards stack into one column, the bars narrow to fit, and the table sits inside a wrapper that scrolls internally so the page itself never scrolls sideways (overflow-x: hidden on html/body as a safety net).
  • 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 1 min 4 s.
  • First words after 13 s.
  • Wrote 6,421 tokens, 795 of them thinking.
  • 34¢ at API prices.
All numbers.
Time to finish1 min 4 s
Time to first words13 s
Input tokens2,328
Cached tokens0
Output tokens6,421
Thinking tokens795
Words in the answer1,409
Price at API rates34¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

0 of 7

The answer was cut off at the token limit.

  • Floor0/2, 0% passed
  • Middle0/2, 0% passed
  • Top0/3, 0% passed
0 of 7 checks passedfloor 0/2, middle 0/2, top 0/3
  • Missed.
    Page loads with no console errors and nothing fetched from outsideNo answer
    Floor Page test
  • Missed.
    Every word and number in the picture is on the pageNo answer
    Floor Page test
  • Missed.
    Wide screen: full-height left sidebar, three cards in a row, table cells in the right columnsNo answer
    Middle Page test
  • Missed.
    Sidebar, active item, accent blue and the three status pills match the pictureNo answer
    Middle Page test
  • Missed.
    Chart bars run Mon to Sun with heights in proportion to their valuesNo answer
    Top Page test
  • Missed.
    On a phone: no sideways scroll, cards stacked, sidebar hidden until a menu button opens itNo answer
    Top Page test
  • Missed.
    Side by side with the picture, a designer would accept it as a faithful buildNo answer
    Top Read
  • Took 6 min 42 s.
  • Wrote 32,000 tokens, 32,000 of them thinking.
  • $1.62 at API prices.
All numbers.
Time to finish6 min 42 s
Time to first wordsnot recorded
Input tokens2,328
Cached tokens0
Output tokens32,000
Thinking tokens32,000
Words in the answer0
Price at API rates$1.62
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 Fable 5.1 · low effort · 2 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.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kanban</title>
<style>
  * { box-sizing: border-box; }
  body {
    margin: 0;
    padding: 12px;
    font-family: system-ui, -apple-system, sans-serif;
    background: #f0f2f5;
    color: #222;
  }
  h1 { font-size: 1.25rem; margin: 0 0 12px; }
  #addForm { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
  #addForm input {
    flex: 1 1 180px; min-width: 0;
    padding: 8px; font-size: 1rem;
    border: 1px solid #bbb; border-radius: 4px;
  }
  button {
    font: inherit; padding: 6px 10px;
    border: 1px solid #888; border-radius: 4px;
    background: #fff; cursor: pointer;
  }
  button:hover { background: #eee; }
  .board {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 12px;
  }
  @media (max-width: 640px) {
    .board { grid-template-columns: minmax(0, 1fr); }
  }
  .column {
    background: #e2e5ea; border-radius: 6px; padding: 8px;
    display: flex; flex-direction: column; min-width: 0;
  }
  .column h2 { font-size: 1rem; margin: 0 0 8px; }
  .column.over { outline: 2px dashed #3a7d5c; }
  .cards { flex: 1; display: flex; flex-direction: column; gap: 8px; min-height: 48px; }
  .card {
    background: #fff; border-radius: 4px; padding: 8px; min-width: 0;
    box-shadow: 0 1px 2px rgba(0,0,0,.2); cursor: grab;
  }
  .card:focus { outline: 2px solid #2d5fd0; outline-offset: 1px; }
  .card.dragging { opacity: .5; }
  .card .title { overflow-wrap: anywhere; white-space: pre-wrap; margin-bottom: 6px; }
  .card .actions { display: flex; gap: 6px; flex-wrap: wrap; }
  .card input {
    width: 100%; min-width: 0; padding: 6px; font-size: 1rem;
    margin-bottom: 6px; border: 1px solid #bbb; border-radius: 4px;
  }
  .hint { font-size: .85rem; color: #555; margin-top: 12px; }
</style>
</head>
<body>
<h1>Kanban</h1>

<form id="addForm" autocomplete="off">
  <input id="newTitle" type="text" placeholder="New card title" aria-label="New card title">
  <button type="submit">Add card</button>
</form>

<div class="board" id="board"></div>
<p class="hint">Tab to a card and use the arrow keys to move it. Drag with the mouse to reorder.</p>

<script>
(function () {
  var STORAGE_KEY = 'simple-kanban-board';
  var COLUMNS = [
    { key: 'todo',  name: 'To do' },
    { key: 'doing', name: 'Doing' },
    { key: 'done',  name: 'Done' }
  ];

  var state = load();
  var editingId = null;   // id of card currently being edited
  var focusId = null;     // id of card to focus after the next render
  var dragged = null;     // DOM element being dragged

  function load() {
    var empty = { todo: [], doing: [], done: [] };
    try {
      var raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) return empty;
      var data = JSON.parse(raw);
      COLUMNS.forEach(function (c) {
        if (!Array.isArray(data[c.key])) data[c.key] = [];
      });
      return data;
    } catch (e) {
      return empty;
    }
  }

  function save() {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  }

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

  // Returns { col: 'todo', index: 2 } for a card id, or null.
  function locate(id) {
    for (var i = 0; i < COLUMNS.length; i++) {
      var list = state[COLUMNS[i].key];
      for (var j = 0; j < list.length; j++) {
        if (list[j].id === id) return { col: COLUMNS[i].key, colIndex: i, index: j };
      }
    }
    return null;
  }

  // ---------- Rendering (DOM API only, so titles are never parsed as HTML) ----------

  var board = document.getElementById('board');

  function render() {
    board.textContent = '';
    COLUMNS.forEach(function (c) {
      var column = document.createElement('section');
      column.className = 'column';
      column.dataset.col = c.key;

      var h2 = document.createElement('h2');
      h2.textContent = c.name + ' ' + state[c.key].length;
      column.appendChild(h2);

      var cards = document.createElement('div');
      cards.className = 'cards';
      cards.dataset.col = c.key;
      cards.addEventListener('dragover', onDragOver);
      cards.addEventListener('dragenter', function (e) { e.preventDefault(); column.classList.add('over'); });
      cards.addEventListener('dragleave', function () { column.classList.remove('over'); });
      cards.addEventListener('drop', function (e) { e.preventDefault(); column.classList.remove('over'); });

      state[c.key].forEach(function (card) {
        cards.appendChild(renderCard(card));
      });

      column.appendChild(cards);
      board.appendChild(column);
    });

    if (editingId) {
      var input = board.querySelector('.card[data-id="' + editingId + '"] input');
      if (input) { input.focus(); input.select(); }
    } else if (focusId) {
      var el = board.querySelector('.card[data-id="' + focusId + '"]');
      if (el) el.focus();
    }
    focusId = null;
  }

  function renderCard(card) {
    var el = document.createElement('div');
    el.className = 'card';
    el.dataset.id = card.id;
    el.tabIndex = 0;
    el.setAttribute('role', 'listitem');
    el.setAttribute('aria-label', card.title);

    var isEditing = card.id === editingId;
    el.draggable = !isEditing;

    if (isEditing) {
      var input = document.createElement('input');
      input.type = 'text';
      input.value = card.title;
      input.setAttribute('aria-label', 'Edit title');
      input.addEventListener('keydown', function (e) {
        if (e.key === 'Enter') { e.preventDefault(); commitEdit(card.id, input.value); }
        if (e.key === 'Escape') { e.preventDefault(); editingId = null; focusId = card.id; render(); }
      });
      el.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 () { commitEdit(card.id, input.value); });
      actions.appendChild(saveBtn);
      el.appendChild(actions);
    } else {
      var title = document.createElement('div');
      title.className = 'title';
      title.textContent = card.title;
      el.appendChild(title);

      var actions2 = document.createElement('div');
      actions2.className = 'actions';

      var editBtn = document.createElement('button');
      editBtn.type = 'button';
      editBtn.textContent = 'Edit';
      editBtn.addEventListener('click', function () { editingId = card.id; render(); });

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

      actions2.appendChild(editBtn);
      actions2.appendChild(delBtn);
      el.appendChild(actions2);

      el.addEventListener('dragstart', function (e) {
        dragged = el;
        el.classList.add('dragging');
        e.dataTransfer.effectAllowed = 'move';
        try { e.dataTransfer.setData('text/plain', card.id); } catch (err) {}
      });
      el.addEventListener('dragend', function () {
        el.classList.remove('dragging');
        dragged = null;
        commitDomOrder();
      });
    }

    el.addEventListener('keydown', function (e) {
      if (e.target !== el) return; // ignore keys typed in the input or on buttons
      var moved = false;
      switch (e.key) {
        case 'ArrowLeft':  moved = moveAcross(card.id, -1); break;
        case 'ArrowRight': moved = moveAcross(card.id, 1); break;
        case 'ArrowUp':    moved = moveWithin(card.id, -1); break;
        case 'ArrowDown':  moved = moveWithin(card.id, 1); break;
        default: return;
      }
      e.preventDefault();
      if (moved) { save(); focusId = card.id; render(); }
    });

    return el;
  }

  // ---------- Actions ----------

  function addCard(title) {
    title = title.trim();
    if (!title) return;
    state.todo.push({ id: newId(), title: title });
    save();
    render();
  }

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

  function commitEdit(id, value) {
    var loc = locate(id);
    if (loc) {
      var trimmed = value.trim();
      if (trimmed) state[loc.col][loc.index].title = trimmed;
    }
    editingId = null;
    focusId = id;
    save();
    render();
  }

  function moveWithin(id, delta) {
    var loc = locate(id);
    if (!loc) return false;
    var list = state[loc.col];
    var to = loc.index + delta;
    if (to < 0 || to >= list.length) return false;
    var tmp = list[loc.index];
    list[loc.index] = list[to];
    list[to] = tmp;
    return true;
  }

  function moveAcross(id, delta) {
    var loc = locate(id);
    if (!loc) return false;
    var toCol = loc.colIndex + delta;
    if (toCol < 0 || toCol >= COLUMNS.length) return false;
    var card = state[loc.col].splice(loc.index, 1)[0];
    var target = state[COLUMNS[toCol].key];
    target.splice(Math.min(loc.index, target.length), 0, card);
    return true;
  }

  // ---------- Drag and drop ----------

  function onDragOver(e) {
    if (!dragged) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    var container = e.currentTarget;
    var over = e.target.closest ? e.target.closest('.card') : null;
    if (over && over !== dragged && container.contains(over)) {
      var rect = over.getBoundingClientRect();
      if (e.clientY < rect.top + rect.height / 2) {
        container.insertBefore(dragged, over);
      } else {
        container.insertBefore(dragged, over.nextSibling);
      }
    } else if (!over && dragged.parentNode !== container) {
      container.appendChild(dragged);
    } else if (!over) {
      // Dragging in empty space below the cards of its own column: move to end.
      var rectC = container.getBoundingClientRect();
      var last = container.lastElementChild;
      if (last && last !== dragged && e.clientY > last.getBoundingClientRect().bottom && e.clientY <= rectC.bottom) {
        container.appendChild(dragged);
      }
    }
  }

  // Read the card order out of the DOM after a drag and make it the state.
  function commitDomOrder() {
    var byId = {};
    COLUMNS.forEach(function (c) {
      state[c.key].forEach(function (card) { byId[card.id] = card; });
    });
    var fresh = {};
    COLUMNS.forEach(function (c) {
      fresh[c.key] = [];
      var container = board.querySelector('.cards[data-col="' + c.key + '"]');
      Array.prototype.forEach.call(container.children, function (el) {
        var card = byId[el.dataset.id];
        if (card) fresh[c.key].push(card);
      });
    });
    state = fresh;
    save();
    render();
  }

  // ---------- Add form ----------

  var form = document.getElementById('addForm');
  var newTitle = document.getElementById('newTitle');
  form.addEventListener('submit', function (e) {
    e.preventDefault();
    addCard(newTitle.value);
    newTitle.value = '';
    newTitle.focus();
  });

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

Notes on how it works:

  • Saving: the whole board lives in localStorage under one key and is rewritten after every change, so a reload gives you exactly the same board. It starts empty.
  • Safe titles: everything is built with createElement/textContent, never innerHTML, so <div> or a & b display literally.
  • Keyboard: each card has tabindex="0". Arrow keys only act when the card itself has focus (not its buttons or edit field), and the card is re-focused after every move. In edit mode, Enter saves and Escape cancels.
  • Drag and drop: uses the browser's native drag API; the card is moved live in the DOM while you drag, and the final order is committed when you release. Note that native drag doesn't work with a finger on most phone browsers, so on a phone use a keyboard or an external one — the arrow keys handle moves there.
  • Phone layout: columns stack into one at widths under 640px, long titles wrap with overflow-wrap: anywhere, and min-width: 0 on grid items prevents any horizontal overflow at 375px.
  • 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 8 s.
  • First words after 24 s.
  • Wrote 6,769 tokens, 1,779 of them thinking.
  • 35¢ at API prices.
All numbers.
Time to finish1 min 8 s
Time to first words24 s
Input tokens1,170
Cached tokens0
Output tokens6,769
Thinking tokens1,779
Words in the answer1,428
Price at API rates35¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

0 of 9

The answer was cut off at the token limit.

  • 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 columnsNo answer
    Floor Page test
  • Missed.
    Adding a card works with Enter and with the Add card buttonNo answer
    Floor Page test
  • Missed.
    Empty or space-only titles are ignoredNo answer
    Middle Page test
  • Missed.
    The counts update, and Edit and Delete workNo answer
    Middle Page test
  • Missed.
    After a reload the board is exactly as it wasNo answer
    Middle Page test
  • Missed.
    Dragging moves cards between columns and reorders them, and it survives a reloadNo answer
    Top Page test
  • Missed.
    A card can be moved with the arrow keys and keeps focusNo answer
    Top Page test
  • Missed.
    Titles like <b>hi</b> & <img ...> show exactly as typed and run nothingNo answer
    Top Page test
  • Missed.
    No sideways scroll on a 375 px phone, even with very long titlesNo answer
    Top Page test
  • Took 6 min 23 s.
  • Wrote 32,000 tokens, 32,000 of them thinking.
  • $1.61 at API prices.
All numbers.
Time to finish6 min 23 s
Time to first wordsnot recorded
Input tokens1,170
Cached tokens0
Output tokens32,000
Thinking tokens32,000
Words in the answer0
Price at API rates$1.61
B04Minesweeper in one HTML fileLow 9/9Max 0/9Max −9

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 9 fewer checks than low.

AI answer from Fable 5.1 · low effort · 2 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.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Minesweeper</title>
<style>
  * { box-sizing: border-box; }
  body {
    margin: 0;
    padding: 12px;
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    background: #f0f0f0;
    color: #222;
    display: flex;
    flex-direction: column;
    align-items: center;
  }
  h1 { font-size: 1.4rem; margin: 0 0 10px; }
  .bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    max-width: 351px;
    margin-bottom: 10px;
    gap: 8px;
  }
  #counter { font-weight: bold; font-size: 1.1rem; }
  #newgame {
    font-size: 1rem;
    padding: 8px 14px;
    min-height: 40px;
    cursor: pointer;
  }
  #message {
    min-height: 1.6em;
    font-size: 1.2rem;
    font-weight: bold;
    margin-bottom: 10px;
  }
  #board {
    display: grid;
    grid-template-columns: repeat(9, 36px);
    grid-auto-rows: 36px;
    gap: 3px;
    user-select: none;
    -webkit-user-select: none;
  }
  #board button {
    width: 36px;
    height: 36px;
    padding: 0;
    margin: 0;
    font-size: 18px;
    font-weight: bold;
    line-height: 1;
    border: 2px solid #999;
    border-radius: 4px;
    background: #cfcfcf;
    cursor: pointer;
    touch-action: manipulation;
  }
  #board button:focus-visible {
    outline: 3px solid #2a6df4;
    outline-offset: 1px;
  }
  #board button.open {
    background: #fff;
    border-color: #ddd;
    cursor: default;
  }
  #board button.mine { background: #ffb3b3; }
  #board.locked button { cursor: default; }
  .n1 { color: #1b4fd6; }
  .n2 { color: #198a19; }
  .n3 { color: #d11a1a; }
  .n4 { color: #0d1a7a; }
  .n5 { color: #7a1a0d; }
  .n6 { color: #0d7a7a; }
  .n7 { color: #000; }
  .n8 { color: #666; }
</style>
</head>
<body>
  <h1>Minesweeper</h1>
  <div class="bar">
    <div id="counter" aria-live="polite">Mines left: 10</div>
    <button id="newgame" type="button">New game</button>
  </div>
  <div id="message" role="status" aria-live="assertive"></div>
  <div id="board" aria-label="Minesweeper board"></div>

<script>
(function () {
  const ROWS = 9, COLS = 9, MINES = 10;
  const boardEl = document.getElementById('board');
  const counterEl = document.getElementById('counter');
  const messageEl = document.getElementById('message');
  const newGameBtn = document.getElementById('newgame');

  let cells = [];        // array of {mine, open, flag, count, el}
  let minesPlaced = false;
  let gameOver = false;
  let flags = 0;
  let openedCount = 0;

  function idx(r, c) { return r * COLS + c; }
  function inBounds(r, c) { return r >= 0 && r < ROWS && c >= 0 && c < COLS; }

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

  function updateCounter() {
    counterEl.textContent = 'Mines left: ' + (MINES - flags);
  }

  function buildBoard() {
    boardEl.innerHTML = '';
    boardEl.classList.remove('locked');
    cells = [];
    minesPlaced = false;
    gameOver = false;
    flags = 0;
    openedCount = 0;
    messageEl.textContent = '';
    updateCounter();

    for (let i = 0; i < ROWS * COLS; i++) {
      const el = document.createElement('button');
      el.type = 'button';
      el.dataset.i = i;
      const r = Math.floor(i / COLS) + 1, c = (i % COLS) + 1;
      el.setAttribute('aria-label', 'Row ' + r + ' column ' + c + ', unopened');
      el.addEventListener('click', onOpen);
      el.addEventListener('contextmenu', onFlag);
      el.addEventListener('keydown', onKey);
      boardEl.appendChild(el);
      cells.push({ mine: false, open: false, flag: false, count: 0, el: el });
    }
  }

  function placeMines(safeIndex) {
    const pool = [];
    for (let i = 0; i < cells.length; i++) if (i !== safeIndex) pool.push(i);
    for (let k = 0; k < MINES; k++) {
      const j = k + Math.floor(Math.random() * (pool.length - k));
      const t = pool[k]; pool[k] = pool[j]; pool[j] = t;
      cells[pool[k]].mine = true;
    }
    for (let i = 0; i < cells.length; i++) {
      cells[i].count = neighbours(i).filter(n => cells[n].mine).length;
    }
    minesPlaced = true;
  }

  function setLabel(i, state) {
    const r = Math.floor(i / COLS) + 1, c = (i % COLS) + 1;
    cells[i].el.setAttribute('aria-label', 'Row ' + r + ' column ' + c + ', ' + state);
  }

  function reveal(start) {
    const stack = [start];
    while (stack.length) {
      const i = stack.pop();
      const cell = cells[i];
      if (cell.open || cell.flag) continue;
      cell.open = true;
      openedCount++;
      cell.el.classList.add('open');
      if (cell.count > 0) {
        cell.el.textContent = cell.count;
        cell.el.classList.add('n' + cell.count);
        setLabel(i, cell.count + ' mines nearby');
      } else {
        cell.el.textContent = '';
        setLabel(i, 'empty');
        neighbours(i).forEach(n => { if (!cells[n].open && !cells[n].flag) stack.push(n); });
      }
    }
  }

  function lose() {
    gameOver = true;
    boardEl.classList.add('locked');
    cells.forEach((cell, i) => {
      if (cell.mine) {
        cell.el.textContent = '💣';
        cell.el.classList.add('mine');
        setLabel(i, 'mine');
      }
    });
    messageEl.textContent = 'Game over';
  }

  function checkWin() {
    if (openedCount === ROWS * COLS - MINES) {
      gameOver = true;
      boardEl.classList.add('locked');
      messageEl.textContent = 'You win!';
    }
  }

  function onOpen(e) {
    if (gameOver) return;
    const i = Number(e.currentTarget.dataset.i);
    const cell = cells[i];
    if (cell.open || cell.flag) return;
    if (!minesPlaced) placeMines(i);
    if (cell.mine) { lose(); return; }
    reveal(i);
    checkWin();
  }

  function toggleFlag(i) {
    if (gameOver) return;
    const cell = cells[i];
    if (cell.open) return;
    cell.flag = !cell.flag;
    flags += cell.flag ? 1 : -1;
    cell.el.textContent = cell.flag ? '🚩' : '';
    setLabel(i, cell.flag ? 'flagged' : 'unopened');
    updateCounter();
  }

  function onFlag(e) {
    e.preventDefault();
    toggleFlag(Number(e.currentTarget.dataset.i));
  }

  // Keyboard extras: Enter/Space open via the native click; F toggles a flag;
  // arrow keys move focus around the grid.
  function onKey(e) {
    const i = Number(e.currentTarget.dataset.i);
    if (e.key === 'f' || e.key === 'F') { e.preventDefault(); toggleFlag(i); return; }
    const r = Math.floor(i / COLS), c = i % COLS;
    let nr = r, nc = c;
    if (e.key === 'ArrowUp') nr--; else if (e.key === 'ArrowDown') nr++;
    else if (e.key === 'ArrowLeft') nc--; else if (e.key === 'ArrowRight') nc++;
    else return;
    e.preventDefault();
    if (inBounds(nr, nc)) cells[idx(nr, nc)].el.focus();
  }

  newGameBtn.addEventListener('click', buildBoard);
  buildBoard();
})();
</script>
</body>
</html>

Notes on how it meets each point:

  • Board: 9×9 with 10 mines; each square is a <button>, so Tab reaches it and Enter/Space open it (the browser fires click). I also added F to flag and arrow keys to move between squares, since right-click isn't available from the keyboard.
  • Flags: right click (contextmenu) toggles 🚩 on unopened squares; flagged squares are ignored by both direct opens and the flood fill until unflagged.
  • Counter: "Mines left" is always 10 minus the current number of flags.
  • Safe first click: mines are only placed after the first open, excluding that square.
  • Numbers/flood fill: counts use the 8 neighbours; zero-count squares open their neighbours iteratively.
  • Win/lose: win when opened squares equal 71 (81 − 10) → "You win!"; opening a mine → "Game over", all mines show 💣, and the board stops responding.
  • New game: rebuilds the board with fresh mines, counter at 10, message cleared.
  • Size: 36px squares with 3px gaps make the grid 348px wide, so it fits a 375px phone with no sideways scrolling.
  • 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.
    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
  • Passed.
    Numbers are right and an empty square opens its neighbours
    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 40 s.
  • First words after 6.6 s.
  • Wrote 3,993 tokens, 324 of them thinking.
  • 21¢ at API prices.
All numbers.
Time to finish40 s
Time to first words6.6 s
Input tokens1,112
Cached tokens0
Output tokens3,993
Thinking tokens324
Words in the answer1,086
Price at API rates21¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

0 of 9

The answer was cut off at the token limit.

Here's the complete file. Save it as something like minesweeper.html and open it in a browser — there's nothing to install or download.

<!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: 36px;   /* square size: at least 32px so it is easy to tap */
    --gap: 2px;
    /* 9 squares + 8 gaps + padding both sides = 344px, which fits a 375px phone */
    --board: calc(9 * var(--cell) + 8 * var(--gap) + 2 * var(--gap));
  }
  * { box-sizing: border-box; }
  html, body { margin: 0; }
  body {
    font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    background: #f3f4f6;
    color: #111;
    padding: 12px 8px 24px;
  }
  main { width: var(--board); max-width: 100%; margin: 0 auto; }
  h1 { font-size: 1.4rem; text-align: center; margin: 0 0 10px; }

  .bar {
    display: flex; justify-content: space-between; align-items: center;
    gap: 8px; margin-bottom: 8px;
  }
  .counter { font-weight: 600; font-variant-numeric: tabular-nums; }
  #new {
    min-height: 36px; padding: 0 14px;
    font: inherit; font-weight: 600;
    border: 1px solid #9ca3af; border-radius: 6px;
    background: #fff; color: inherit; cursor: pointer;
  }
  #new:hover { background: #eef2ff; }

  #message {
    min-height: 1.5em; margin: 0 0 8px;
    text-align: center; font-weight: 700; font-size: 1.15rem;
  }
  #message.win  { color: #15803d; }
  #message.lose { color: #b91c1c; }

  #board {
    display: grid;
    grid-template-columns: repeat(9, var(--cell));
    grid-auto-rows: var(--cell);
    gap: var(--gap);
    padding: var(--gap);
    background: #9ca3af;
    width: var(--board);
  }
  .cell {
    width: var(--cell); height: var(--cell);
    margin: 0; padding: 0;
    display: flex; align-items: center; justify-content: center;
    font: 700 18px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
    color: #111;
    background: #d1d5db;
    border: 2px solid;
    border-color: #f3f4f6 #6b7280 #6b7280 #f3f4f6;   /* raised look */
    -webkit-appearance: none; appearance: none;
    cursor: pointer;
    user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
    -webkit-tap-highlight-color: transparent;
    touch-action: manipulation;   /* no double-tap zoom; long press still works */
  }
  .cell.open  { background: #ecedef; border: 1px solid #b4b8bf; cursor: default; }
  .cell.wrong { background: #fecaca; }   /* a flag that was on a safe square */
  .cell.mine  { background: #fde68a; }
  .cell.boom  { background: #ef4444; }   /* the mine that was opened */
  .cell:focus-visible { outline: 3px solid #2563eb; outline-offset: -3px; }
  #board.over .cell { cursor: not-allowed; }

  .n1 { color: #1d4ed8; } .n2 { color: #15803d; } .n3 { color: #dc2626; } .n4 { color: #1e3a8a; }
  .n5 { color: #7f1d1d; } .n6 { color: #0f766e; } .n7 { color: #000; }    .n8 { color: #6b7280; }

  .help { font-size: 0.85rem; color: #4b5563; margin: 12px 0 0; line-height: 1.4; }
</style>
</head>
<body>
<main>
  <h1>Minesweeper</h1>

  <div class="bar">
    <div class="counter">Mines left: <span id="count">10</span></div>
    <button type="button" id="new">New game</button>
  </div>

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

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

  <p class="help">
    Open a square: left click, or Enter / Space when it is focused.
    Flag a square: right click, long press on a touch screen, or press F when it is focused.
    Arrow keys move between squares.
  </p>
</main>

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

  const W = 9, H = 9, N = W * H, MINES = 10;
  const LONG_PRESS_MS = 400;

  const boardEl = document.getElementById('board');
  const countEl = document.getElementById('count');
  const msgEl   = document.getElementById('message');
  const newBtn  = document.getElementById('new');

  // Game state (one entry per square)
  let mine, open, flag, count;
  let started, over, flags, boom;

  // Touch long-press bookkeeping
  let pressTimer = null, longPressed = false, ignoreClickUntil = 0, startX = 0, startY = 0;

  // Create the 81 buttons once; render() updates them.
  const cells = [];
  for (let i = 0; i < N; i++) {
    const b = document.createElement('button');
    b.type = 'button';
    b.className = 'cell';
    b.dataset.i = i;
    boardEl.appendChild(b);
    cells.push(b);
  }

  function neighbours(i) {
    const r = Math.floor(i / W), c = i % W, out = [];
    for (let dr = -1; dr <= 1; dr++) {
      for (let dc = -1; dc <= 1; dc++) {
        if (dr === 0 && dc === 0) continue;
        const rr = r + dr, cc = c + dc;
        if (rr >= 0 && rr < H && cc >= 0 && cc < W) out.push(rr * W + cc);
      }
    }
    return out;
  }

  function newGame() {
    mine  = new Array(N).fill(false);
    open  = new Array(N).fill(false);
    flag  = new Array(N).fill(false);
    count = new Array(N).fill(0);
    started = false;
    over = false;
    flags = 0;
    boom = -1;
    msgEl.textContent = '';
    msgEl.className = '';
    boardEl.classList.remove('over');
    render();
  }

  // Mines are placed when the first square is opened, avoiding that square
  // and its neighbours, so the first square you open is never a mine.
  function placeMines(safe) {
    const banned = new Set(neighbours(safe));
    banned.add(safe);
    const spots = [];
    for (let i = 0; i < N; i++) if (!banned.has(i)) spots.push(i);
    for (let k = 0; k < MINES; k++) {            // partial Fisher–Yates shuffle
      const j = k + Math.floor(Math.random() * (spots.length - k));
      [spots[k], spots[j]] = [spots[j], spots[k]];
      mine[spots[k]] = true;
    }
    for (let i = 0; i < N; i++) {
      if (!mine[i]) count[i] = neighbours(i).filter(n => mine[n]).length;
    }
    started = true;
  }

  function openCell(i) {
    if (over || open[i] || flag[i]) return;      // flagged squares can't be opened
    if (!started) placeMines(i);
    if (mine[i]) { lose(i); return; }

    // Open the square; a square with no mines around it opens its neighbours too.
    open[i] = true;
    const stack = [i];
    while (stack.length) {
      const j = stack.pop();
      if (count[j] !== 0) continue;
      for (const n of neighbours(j)) {
        if (!open[n] && !flag[n] && !mine[n]) { open[n] = true; stack.push(n); }
      }
    }
    checkWin();
    render();
  }

  function toggleFlag(i) {
    if (over || open[i]) return;
    flag[i] = !flag[i];
    flags += flag[i] ? 1 : -1;
    render();
  }

  function lose(i) {
    over = true;
    boom = i;
    boardEl.classList.add('over');
    msgEl.textContent = 'Game over';
    msgEl.className = 'lose';
    render();
  }

  function checkWin() {
    for (let i = 0; i < N; i++) if (!mine[i] && !open[i]) return;
    over = true;
    boardEl.classList.add('over');
    // Flag any mines still unflagged so the counter reads 0.
    for (let i = 0; i < N; i++) if (mine[i] && !flag[i]) { flag[i] = true; flags++; }
    msgEl.textContent = 'You win!';
    msgEl.className = 'win';
  }

  function render() {
    countEl.textContent = MINES - flags;
    const lost = boom >= 0;
    for (let i = 0; i < N; i++) {
      const b = cells[i];
      let cls = 'cell', text = '', state;
      if (lost && mine[i]) {
        cls += ' open mine' + (i === boom ? ' boom' : '');
        text = '💣';
        state = 'mine';
      } else if (flag[i]) {
        cls += ' flag' + (lost ? ' wrong' : '');
        text = '🚩';
        state = lost ? 'flagged, no mine here' : 'flagged';
      } else if (open[i]) {
        cls += ' open';
        if (count[i] > 0) {
          cls += ' n' + count[i];
          text = String(count[i]);
          state = count[i] + (count[i] === 1 ? ' mine' : ' mines') + ' nearby';
        } else {
          state = 'empty';
        }
      } else {
        state = 'unopened';
      }
      b.className = cls;
      b.textContent = text;
      b.setAttribute('aria-label',
        'Row ' + (Math.floor(i / W) + 1) + ', column ' + (i % W + 1) + ', ' + state);
      if (over) b.setAttribute('aria-disabled', 'true'); else b.removeAttribute('aria-disabled');
    }
  }

  function cellIndex(e) {
    const b = e.target && e.target.closest ? e.target.closest('.cell') : null;
    return b ? Number(b.dataset.i) : -1;
  }

  // ---- Mouse and keyboard ----

  // A <button> turns Enter and Space into a click, so this covers both.
  boardEl.addEventListener('click', e => {
    if (performance.now() < ignoreClickUntil) return;   // tail end of a long press
    const i = cellIndex(e);
    if (i >= 0) openCell(i);
  });

  // Right click puts a flag on, or takes one off. The browser menu is suppressed.
  boardEl.addEventListener('contextmenu', e => {
    e.preventDefault();
    const i = cellIndex(e);
    if (i < 0) return;
    clearTimeout(pressTimer); pressTimer = null;
    if (longPressed) return;          // our own long press already toggled it
    longPressed = true;
    toggleFlag(i);
  });

  // Extra keyboard help: F toggles a flag, arrow keys move around the board.
  boardEl.addEventListener('keydown', e => {
    if (e.ctrlKey || e.metaKey || e.altKey) return;
    const i = cellIndex(e);
    if (i < 0) return;
    let r = Math.floor(i / W), c = i % W;
    switch (e.key) {
      case 'f': case 'F': e.preventDefault(); toggleF
  • 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.
    81 square buttons in a 9 by 9 grid, Mines left starts at 10test group failed
    Floor Page test
  • Missed.
    A first click never lands on a minetest group failed
    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
  • Missed.
    The squares work from the keyboardtest group failed
    Middle Page test
  • Missed.
    Hitting a mine says Game over, shows all 10 mines as 💣 and stops the boardtest group failed
    Top Page test
  • Missed.
    Opening every safe square shows You win! (and not before)test group failed
    Top Page test
  • Missed.
    New game resets the board, counter and messagetest group failed
    Top Page test
  • Missed.
    Fits a 375 px phone with squares of at least 32 px, no sideways scrolltest group failed
    Top Page test
  • Took 5 min 58 s.
  • First words after 5 min 25 s.
  • Wrote 32,000 tokens, 27,873 of them thinking.
  • $1.61 at API prices.
All numbers.
Time to finish5 min 58 s
Time to first words5 min 25 s
Input tokens1,112
Cached tokens0
Output tokens32,000
Thinking tokens27,873
Words in the answer1,354
Price at API rates$1.61
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 Fable 5.1 · low effort · 2 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, all fixable in CSS only. What was happening:

  1. Sideways scroll — the fees table has a fixed width: 720px, which is wider than any phone, and the long email address is one unbreakable word that overflows its box. Both push the page out sideways.
  2. Headings hidden under the header — a sticky header sits on top of the page, so when the browser jumps to #events it puts the heading at the very top, behind the header. scroll-margin-top tells the browser to stop short by the header's height.
  3. Squashed cards — the grid is always repeat(3, 1fr); it needs to drop to one column on narrow screens.

Here is the whole file. Changes are marked with /* changed */ or /* new */ comments in the CSS; the HTML is identical except that the table is wrapped in a <div class="table-scroll"> so that, on the very narrowest phones, the table scrolls inside its own box rather than dragging the whole page sideways.

<!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; }
  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: .4rem 1.1rem; } /* changed: links may wrap on tiny screens */
  .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;
    scroll-margin-top: 4.5rem; /* new: stop short of the sticky header when jumping to a section */
  }
  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; }
  .table-scroll { max-width: 100%; overflow-x: auto; } /* new: table scrolls inside its own box if it must */
  .fees { width: 100%; max-width: 720px; border-collapse: collapse; background: #fff; } /* changed: was width: 720px */
  .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; overflow-wrap: anywhere; } /* changed: long address may break across lines */
  .email a { color: #1f4424; }
  footer { background: #23301f; color: #d8dccc; padding: 1.5rem 0; font-size: .9rem; }

  /* new: phone layout */
  @media (max-width: 640px) {
    .cards { grid-template-columns: 1fr; }     /* one card per row */
    section.block { scroll-margin-top: 6.5rem; } /* header is two rows tall here (brand above links) */
  }
</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>

On a laptop nothing changes: the table is still 720px wide (max-width: 720px with width: 100% gives the same size inside the 960px wrap), the cards are still three across, and the header is still sticky. If, after trying it, a heading still sits a few pixels under the header on your particular phone, nudge the 6.5rem in the media query up slightly; if there's too much gap, nudge it down.

  • 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 45 s.
  • First words after 15 s.
  • Wrote 4,048 tokens, 830 of them thinking.
  • 24¢ at API prices.
All numbers.
Time to finish45 s
Time to first words15 s
Input tokens3,362
Cached tokens0
Output tokens4,048
Thinking tokens830
Words in the answer979
Price at API rates24¢

AI answer from Fable 5.1 · max effort · 2 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.

Here's what was going on, then the full fixed file.

1. Sideways scrolling – three things were wider than a phone screen:

  • The fee table was fixed at width: 720px.
  • The email address is one unbreakable 56‑character "word", so it ran off the right edge of the contact box.
  • The event grid forces three columns, and a column can't get narrower than its longest word, so the three cards together overflowed the screen.

Fixes: the table is now width: 100%; max-width: 720px (still exactly 720px on a laptop) inside a wrapper that scrolls on its own if it ever can't shrink enough; the email is allowed to break across lines; the cards drop to one column on phones.

2. Headings hidden under the header – a jump link scrolls the target to the very top edge of the window, and the sticky header then sits on top of it. scroll-margin-top on the sections tells the browser to stop a bit short of the top, by a little more than the header height (about 57px on a laptop, about 86px on a phone where the club name and links stack into two lines).

3. Cards – a media query at 640px wide switches the grid to one card per row. Nothing inside that media query applies above 640px, so the laptop layout is untouched.

Two small extras I added for phones only: the nav links can wrap as whole links on very narrow screens, and the table cells have slightly tighter padding so all four columns fit without ugly word-splitting.

<!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; }
  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; }
  /* flex-wrap lets the links drop to a second line on very narrow screens instead of overflowing */
  .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; }
  /* scroll-margin-top: when a link jumps to a section, stop this far short of the top
     so the sticky header (about 57px tall on a laptop) does not cover the heading */
  section.block { padding: 2rem 0; scroll-margin-top: 4rem; }
  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; }
  /* The table is still 720px on a laptop but can shrink on small screens. If it ever
     cannot shrink enough, the wrapper scrolls sideways instead of the whole page. */
  .table-scroll { overflow-x: auto; }
  .fees { width: 100%; max-width: 720px; 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; }
  /* Let the long email address break onto a second line when it is wider than the screen */
  .email { font-size: 1.1rem; font-weight: 600; overflow-wrap: break-word; overflow-wrap: anywhere; }
  .email a { color: #1f4424; }
  footer { background: #23301f; color: #d8dccc; padding: 1.5rem 0; font-size: .9rem; }

  /* Phones and other narrow screens. Nothing in here applies above 640px wide. */
  @media (max-width: 640px) {
    /* The header is two lines tall here (name above the links), so leave more room */
    section.block { scroll-margin-top: 6rem; }
    /* One event card per row */
    .cards { grid-template-columns: 1fr; }
    /* Slightly tighter cells so all four fee columns fit on a phone screen */
    .fees th, .fees td { padding: .6rem; }
  }
</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>

Two things to know for later:

  • To preview the phone layout on your laptop, just drag the browser window narrower than 640px.
  • If you ever make the header taller (bigger text, more padding, another row of links), increase the two scroll-margin-top values to match, otherwise the headings will start creeping back under 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 5 min 58 s.
  • First words after 5 min 29 s.
  • Wrote 30,017 tokens, 26,515 of them thinking.
  • $1.53 at API prices.
All numbers.
Time to finish5 min 58 s
Time to first words5 min 29 s
Input tokens3,362
Cached tokens0
Output tokens30,017
Thinking tokens26,515
Words in the answer1,146
Price at API rates$1.53

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 Fable 5.1 · low effort · 2 Oct 2026

7 of 7

What was wrong

The cursor is just a created_at value, and the ordering is ORDER BY created_at DESC alone. That breaks in two ways when timestamps collide (which the importer guarantees):

  1. Ties are unordered. SQLite is free to return rows with equal created_at in any order, and it can vary between calls, so the same tie group can come out shuffled on consecutive pages — some posts appear twice, others never.
  2. The cursor can't point inside a tie group. The next page uses created_at <= cursor, which re-includes every post at that timestamp (duplicates). Had it used <, it would skip every post at that timestamp that didn't fit on the previous page (missing posts). Neither comparison is correct because a timestamp alone doesn't identify a position.

Busy days produce more identical timestamps, so it shows up more then. The fix is a keyset cursor over the full sort key (created_at DESC, id DESC): encode both values in the cursor and continue strictly after that exact row.

Fixed function

import sqlite3

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


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.
    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.

    Ordering is (created_at DESC, id DESC), so posts that share a
    created_at come out highest id first. The cursor encodes the last
    (created_at, id) returned, and the next page continues strictly
    after that row.
    """
    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:
        try:
            ts_str, id_str = cursor.split(":", 1)
            last_ts, last_id = int(ts_str), int(id_str)
        except (AttributeError, ValueError):
            raise ValueError("invalid 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_ts, last_ts, last_id, limit + 1),
        )

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

    next_cursor = f"{rows[-1][3]}:{rows[-1][0]}" if has_more else None
    return rows, next_cursor

Two notes:

  • The cursor format changed ("<created_at>:<id>"), so cursors issued by the old version won't parse. If clients might still hold old ones, treat a cursor without a : as a first-page request rather than raising.
  • For performance, add an index that matches the sort key: CREATE INDEX IF NOT EXISTS posts_feed ON posts(created_at DESC, id DESC); — this lets SQLite walk straight to the cursor position instead of scanning.
  • 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 15 s.
  • First words after 1.8 s.
  • Wrote 1,199 tokens.
  • 7.4¢ at API prices.
All numbers.
Time to finish15 s
Time to first words1.8 s
Input tokens1,409
Cached tokens0
Output tokens1,199
Thinking tokens0
Words in the answer457
Price at API rates7.4¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

7 of 7

The paging arithmetic is fine; the sort key and the cursor are the problem.

What was wrong

  • ORDER BY created_at DESC on its own doesn't define a unique order. SQLite makes no promise about how posts with the same timestamp are ordered relative to each other, nor that two separate queries will agree on it. So a cursor that is only a timestamp can't name an exact position in the list.
  • The next page starts at created_at <= cursor. That always re-includes the last post of the previous page, plus every other post with that timestamp, in whatever order SQLite produces this time — some repeat, some get pushed out of the window and are never seen. Changing it to < would instead silently skip all the other posts with that timestamp. Worse, if more than limit posts share one timestamp (an importer batch, or a busy second), next_cursor comes out as the same timestamp again, so the app loops on that group and never gets past it.
  • Busy days mean more posts per second and bigger import batches, so more ties and more symptoms.

Fix: sort on (created_at DESC, id DESC) — a total order because id is unique — put both values in the cursor, and ask the next page for rows strictly after that pair in sort order.

import sqlite3

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


def _encode_cursor(created_at, post_id):
    return f"{created_at}:{post_id}"


def _decode_cursor(cursor):
    try:
        created_at, post_id = str(cursor).split(":")
        return int(created_at), int(post_id)
    except ValueError:
        raise ValueError(f"malformed cursor: {cursor!r}") from None


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.
    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.

    Order is (created_at DESC, id DESC): newest first, and among posts with
    the same created_at the higher id first.  id is unique, so that order is
    total and the cursor (last created_at, last id) names an exact position.
    """
    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:
        created_at, post_id = _decode_cursor(cursor)
        # Rows strictly after (created_at, post_id) in the sort order:
        # created_at < ts, or created_at == ts and id < post_id.
        # Written with a leading "created_at <= ?" so an index on
        # (created_at, id) can seek straight to the cursor position.
        cur = conn.execute(
            f"SELECT {COLUMNS} FROM posts "
            "WHERE created_at <= ? AND (created_at < ? OR id < ?) "
            "ORDER BY created_at DESC, id DESC LIMIT ?",
            (created_at, created_at, post_id, limit + 1),
        )

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

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

Two small notes

  • Add CREATE INDEX IF NOT EXISTS posts_by_time ON posts(created_at, id); so each page is an index seek rather than a scan-and-sort. (On SQLite 3.15+ the WHERE could also be written (created_at, id) < (?, ?); the form above works on any version.)
  • Cursors issued by the old code are a bare timestamp and will now raise ValueError, so anyone mid-scroll during the deploy needs a refresh. If you want to accept them for a while, treat a cursor with no : as (ts, 2**63 - 1), which reproduces the old created_at <= ts behaviour for that single request and then hands out new-style cursors from there.
  • 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 3 min 7 s.
  • First words after 2 min 55 s.
  • Wrote 15,385 tokens, 13,878 of them thinking.
  • 78¢ at API prices.
All numbers.
Time to finish3 min 7 s
Time to first words2 min 55 s
Input tokens1,409
Cached tokens0
Output tokens15,385
Thinking tokens13,878
Words in the answer611
Price at API rates78¢
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 Fable 5.1 · low effort · 2 Oct 2026

7 of 7

Both bugs come from the same root cause: every onInput call runs independently and has no idea whether it is still the newest one. So when a slower, older request resolves after a newer one, it calls render with stale results (bug 1), and its finally calls setLoading(false) even though the newer request is still in flight (bug 2). The same thing can clobber an emptied box: you clear the text, render([]) runs, and then an old request lands and draws its results anyway.

Since the client can't be cancelled, the fix is to ignore stale responses on our side. The controller keeps a sequence number; each onInput call takes the next number, and after the await it checks whether it is still the latest before touching the UI. Emptying the box also bumps the number, so anything still in flight is retired.

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) {
  // Sequence number of the most recent onInput call. Any request whose
  // number is lower than this is stale and must not touch the UI.
  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 === "") {
        // Clearing the box retires every in-flight request (they see
        // that `latest` has moved on), so nothing can repopulate the list.
        render([]);
        setLoading(false);
        return;
      }

      setLoading(true);

      let results: string[] | undefined;
      let error: Error | undefined;
      try {
        results = await fetchResults(query);
      } catch (err) {
        error = err instanceof Error ? err : new Error(String(err));
      }

      // A newer keystroke (or a clear) happened while we were waiting.
      // That call owns the list and the spinner now; do nothing.
      if (id !== latest) {
        return;
      }

      if (error) {
        render([], error);
      } else {
        render(results ?? []);
      }
      setLoading(false);
    },
  };
}

What changed, briefly:

  • Stale results: id !== latest after the await means a newer input has arrived, so the response is discarded instead of rendered. Responses can arrive in any order and only the newest one wins.
  • Spinner going off early: setLoading(false) is now only called by the request that is still the latest, or when the box is cleared. Older requests finishing (successfully or not) no longer touch the spinner, so it stays on exactly while the newest search is pending.
  • Emptied box: clearing bumps the sequence number, clears the list and turns the spinner off; any older request that completes afterwards fails the id !== latest check and is ignored.
  • The try/finally was replaced by capturing the outcome and deciding afterwards, since the "is this still current" check has to happen after the await and before any UI call, including the one in finally.
  • Calling setLoading(true) on every keystroke while already loading is harmless (it is idempotent), so the spinner simply stays on across rapid typing.
  • 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 17 s.
  • First words after 3.9 s.
  • Wrote 1,309 tokens, 155 of them thinking.
  • 7.9¢ at API prices.
All numbers.
Time to finish17 s
Time to first words3.9 s
Input tokens1,360
Cached tokens0
Output tokens1,309
Thinking tokens155
Words in the answer550
Price at API rates7.9¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

7 of 7

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) {
  // Sequence number of the most recent input. Every onInput call takes the
  // next number, and a request may only touch the list or the spinner if its
  // number is still the latest when it settles. Requests can't be cancelled,
  // so this is how the ones that have been overtaken are made harmless.
  let latest = 0;

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

      if (query === "") {
        // Taking a new number above has already made every in-flight request
        // stale, so none of them can repaint the list or the spinner later.
        render([]);
        setLoading(false);
        return;
      }

      setLoading(true);

      let results: string[] = [];
      let error: Error | undefined;
      try {
        results = await fetchResults(query);
      } catch (err) {
        error = err instanceof Error ? err : new Error(String(err));
      }

      // The user may have typed more, or cleared the box, while we were
      // waiting. If so this response is for an old query: drop it without
      // touching anything. The UI belongs to the newer call now.
      if (id !== latest) return;

      if (error) {
        render([], error);
      } else {
        render(results);
      }
      setLoading(false);
    },
  };
}

What was wrong

Both bugs have the same cause: each onInput call is independent, and when its request settles it unconditionally touches the UI, even though by then it may no longer be the newest request. Since requests can't be cancelled and can finish in any order:

  • Stale list. If the request for "ab" finishes before the request for "a", the "a" request then calls render and overwrites the list with results for text that is no longer in the box.
  • Spinner. finally { setLoading(false) } ran whenever any request finished, so an older request completing turned the spinner off while the newest one was still loading.
  • Clearing the box. The empty-text path rendered [] but never turned the spinner off, and an in-flight request could still finish afterwards and refill the list.

The fix

A counter, latest, lives in the closure. Every onInput call takes the next number before doing anything, including the empty-text path. After the await, the call checks whether it is still the latest; if not, it returns without rendering or touching the spinner, so its results or error are simply dropped. Only the newest request paints and turns the spinner off, and because clearing the box also bumps the counter, the list stays empty and the spinner stays off no matter when older requests finish. A stale call's promise still resolves once its request settles; it just does nothing. fetchResults is still called on every input, and nothing in the file needs a compile step.

  • 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 2 min 24 s.
  • First words after 2 min 16 s.
  • Wrote 12,600 tokens, 11,524 of them thinking.
  • 64¢ at API prices.
All numbers.
Time to finish2 min 24 s
Time to first words2 min 16 s
Input tokens1,360
Cached tokens0
Output tokens12,600
Thinking tokens11,524
Words in the answer532
Price at API rates64¢
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 Fable 5.1 · low effort · 2 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) {
  // Work out the signup's calendar date as the customer sees it.
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone,
    year: 'numeric',
    month: 'numeric',
    day: 'numeric',
  }).formatToParts(new Date(signupIso));
  const part = (type) => Number(parts.find((p) => p.type === type).value);
  const signupYear = part('year');
  const signupMonth = part('month'); // 1-12
  const signupDay = part('day');     // the anchor day; never changes

  const dates = [];
  for (let i = 1; i <= count; i++) {
    // Pure calendar arithmetic from the original signup month.
    const totalMonths = signupYear * 12 + (signupMonth - 1) + i;
    const year = Math.floor(totalMonths / 12);
    const month = totalMonths % 12; // 0-11
    const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
    const day = Math.min(signupDay, lastDay);
    dates.push(
      `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
    );
  }
  return dates;
}

What was wrong:

  1. It used the UTC date, not the customer's local date. A signup on the morning of the 1st in Auckland is still the 28th/30th/31st in UTC, so every renewal was anchored to the wrong day and came out a day early. The fix reads the signup's year/month/day in the customer's time zone with Intl.DateTimeFormat, then does plain calendar arithmetic on those numbers, so no time-of-day or UTC conversion can shift the date again.
  2. The anchor day drifted after a clamp. day was re-read from d on every loop pass, so once January 31st was clamped to February 28th the anchor became 28 and stayed there for every later month. The fix keeps the original signup day and re-applies the "last day of month" rule fresh for each month, counting months from the signup rather than from the previous renewal.
  • 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 18 s.
  • First words after 8.7 s.
  • Wrote 1,688 tokens, 680 of them thinking.
  • 9.8¢ at API prices.
All numbers.
Time to finish18 s
Time to first words8.7 s
Input tokens1,383
Cached tokens0
Output tokens1,688
Thinking tokens680
Words in the answer418
Price at API rates9.8¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

7 of 7

Here is the corrected 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 signup = new Date(signupIso);
  if (Number.isNaN(signup.getTime())) {
    throw new RangeError(`Invalid signup date: ${signupIso}`);
  }

  // The signup's calendar date as the customer saw it, not its UTC date.
  const parts = new Intl.DateTimeFormat("en-US", {
    timeZone,
    calendar: "gregory",
    year: "numeric",
    month: "numeric",
    day: "numeric",
  }).formatToParts(signup);
  const field = (type) => Number(parts.find((p) => p.type === type).value);
  const signupYear = field("year");
  const signupMonth = field("month"); // 1-12
  const signupDay = field("day");

  const dates = [];
  for (let i = 1; i <= count; i++) {
    // Add i months to the ORIGINAL signup date, so a clamped renewal
    // (e.g. Feb 28) never becomes the base for the next one.
    const monthIndex = signupMonth - 1 + i; // 0-based, may run past 11
    const year = signupYear + Math.floor(monthIndex / 12);
    const month = (monthIndex % 12) + 1;
    // Day 0 of the following month is the last day of this month.
    const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
    const day = Math.min(signupDay, lastDay);
    dates.push(
      `${String(year).padStart(4, "0")}-` +
        `${String(month).padStart(2, "0")}-` +
        `${String(day).padStart(2, "0")}`,
    );
  }
  return dates;
}

What was wrong:

  1. timeZone was never used. The signup day was taken from the UTC date (getUTCDate) and the output was the UTC date too. Auckland is UTC+12/+13, so a morning signup on the 1st is still the previous day in UTC — the 28th, 30th or 31st — and every renewal was anchored to that day. That's the "renewed a day early" ticket. The fix reads the signup's year/month/day in the customer's zone via Intl.DateTimeFormat (Node ships full ICU, so no library is needed).
  2. Renewals were chained instead of counted from the signup. The loop mutated d and re-read day from it each time, so once a short month clamped the date (Jan 31 → Feb 28), 28 silently became the new "signup day" and every later month used it. That's the "28th ever since February" ticket. The fix adds i months to the original local date on each iteration and clamps only that result, so Jan 31 gives Feb 28, Mar 31, Apr 30, May 31, …

Because the result is a list of local calendar dates, the arithmetic stays entirely in the calendar — there's no conversion back to an instant, so DST changes can't shift anything. As a quick check, the example in the header, "2025-01-31T23:30:00Z" in Pacific/Auckland, is Feb 1 locally, so the first renewal is now 2025-03-01 rather than the old 2025-02-28.

  • 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 1 min 30 s.
  • First words after 1 min 18 s.
  • Wrote 8,097 tokens, 6,736 of them thinking.
  • 42¢ at API prices.
All numbers.
Time to finish1 min 30 s
Time to first words1 min 18 s
Input tokens1,383
Cached tokens0
Output tokens8,097
Thinking tokens6,736
Words in the answer556
Price at API rates42¢
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 Fable 5.1 · low effort · 2 Oct 2026

7 of 7

Must fix before merging (most important first)

  1. GET /invoices/:id leaks other accounts' invoices. findInvoiceById is never scoped to req.user.accountId, so any logged-in user can read any invoice by guessing or enumerating IDs. Add AND account_id = $2 to the query (or compare invoice.account_id after the fetch) and return 404 in both cases so you don't confirm the ID exists.
  2. to is documented as inclusive but the query excludes it. created_at < $3 with to = 2026-01-31 drops everything on the 31st (whether created_at is a timestamp or a date). Either add a day before passing it down, or use created_at < $3::date + 1 in SQL. Add a test for an invoice created on the to day.
  3. Date validation is shape-only. 2026-13-45 passes the regex, Postgres rejects it, and the handler throws. Unless you're on Express 5 (which forwards rejected promises to the error handler), that's an unhandled rejection rather than a 400. Either parse the dates properly (e.g. Date.parse + round-trip check) or wrap the handlers; also reject from > to with a 400 rather than silently returning an empty list.

Worth knowing

  • The sort/dir whitelist is done correctly (Object.hasOwn, request never supplies SQL text). Invalid values fall back silently to created_at DESC; a 400 would be friendlier but it's a judgment call.
  • Day boundaries depend on the DB session timezone if created_at is timestamptz. Decide whether "a day" means UTC or the account's zone and document it.
  • The fmtMoney → formatMoney rename touches receipts.ts only in this diff. Grep for any other callers (tests, scripts) before merging, or ship the rename separately to keep this PR focused.
  • No tests are included. At minimum cover: cross-account 404, inclusive to, invalid dates, and sort/dir fallbacks.
  • /:id before / is fine for Express routing; no conflict.

Fix 1 is a security bug and should block the merge on its own; 2 is a correctness bug that will surface as "missing invoices" reports.

  • 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 20 s.
  • First words after 11 s.
  • Wrote 1,520 tokens, 758 of them thinking.
  • 11¢ at API prices.
All numbers.
Time to finish20 s
Time to first words11 s
Input tokens3,192
Cached tokens0
Output tokens1,520
Thinking tokens758
Words in the answer329
Price at API rates11¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

7 of 7

Must fix before merging

  1. GET /invoices/:id isn't scoped to the account (IDOR). findInvoiceById filters on id only; requireLogin proves the caller is logged in, not that they own the invoice, so any user can read anyone's invoice by ID. Query WHERE id = $1 AND account_id = $2 with req.user.accountId, and keep answering 404 (not 403) so you don't confirm the ID exists.
  2. to is exclusive, but the comment and docs say inclusive. created_at < $3 with to=2026-01-31 means < 2026-01-31 00:00:00, so the whole last day is dropped — for both timestamp and date columns. Use created_at < $3::date + 1 (or add a day in the route) and add a test for it.
  3. Bad input reaches Postgres as an exception. The regex accepts 2026-13-45, and if invoices.id is uuid, /invoices/foo fails on the cast — 500s at best. On Express 4 without express-async-errors, rejected async handlers aren't caught: the request hangs, and on Node ≥15 the unhandled rejection can kill the process, so ?from=2026-99-99 becomes a one-request DoS. The receipts route has the same pattern, so you may already be covered — verify. Either way, validate dates with a real parser (and reject from > to), check the ID format, and return 400/404.

Worth knowing

  • Unknown sort/dir (including ASC) silently fall back to defaults while bad dates get 400. Pick one behaviour and document it.
  • The sort whitelist is correct: Object.hasOwn blocks __proto__/constructor, and only map values reach the SQL. No injection path.
  • No LIMIT/pagination, and ORDER BY created_at has no tiebreaker (, id), so equal timestamps come back in unstable order — matters once clients page.
  • If created_at is timestamptz, day boundaries follow the DB session time zone, not the user's.
  • The fmtMoney → formatMoney rename is unrelated to the feature. Harmless if tsc passes (it'll catch any caller you missed), but better as its own commit.
  • No tests in the diff. Minimum: another account's ID → 404, to inclusive, bad date → 400.
  • Docs: mark from/to required, say /invoices/:id only returns the signed-in account's invoices, and list the 400/404 responses.
  • 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 2 min 9 s.
  • First words after 2 min 3 s.
  • Wrote 11,179 tokens, 10,313 of them thinking.
  • 59¢ at API prices.
All numbers.
Time to finish2 min 9 s
Time to first words2 min 3 s
Input tokens3,192
Cached tokens0
Output tokens11,179
Thinking tokens10,313
Words in the answer354
Price at API rates59¢
F05SQL: which customers left and came backLow 7/7Max 0/7Max −7

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.

Max effort passed 7 fewer checks than low.

AI answer from Fable 5.1 · low effort · 2 Oct 2026

7 of 7

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

How it works:

  • For each customer, periods are walked in start_date order (ties broken by id, purely for determinism). The window function computes, for each period, the latest end_date among all periods that started earlier (ROWS ... 1 PRECEDING excludes the current row). That running maximum is the last day of access the customer had reached before this period began, no matter how the earlier periods touched, overlapped or nested.
  • A still-active period has end_date replaced with '9999-12-31', so once a customer has an open-ended period nothing after it can ever count as a gap.
  • A gap exists only when the period starts strictly after the day following that running maximum (start_date > prev_end + 1 day). Touching periods, same-day starts, overlaps and nested periods all fail this test, so they're skipped. The first period of each customer has prev_end = NULL and is excluded, which also drops customers who left and never returned.
  • days_without is the day difference between came_back and last_access minus one, i.e. the whole days in between (June 30 → July 10 gives 9).
  • Dates are ISO YYYY-MM-DD strings, so plain string comparison and MAX order them correctly, and date() / julianday() handle the arithmetic. Window functions and this syntax are supported well before SQLite 3.53.
  • 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 21 s.
  • First words after 13 s.
  • Wrote 1,827 tokens, 1,042 of them thinking.
  • 11¢ at API prices.
All numbers.
Time to finish21 s
Time to first words13 s
Input tokens1,683
Cached tokens0
Output tokens1,827
Thinking tokens1,042
Words in the answer293
Price at API rates11¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

0 of 7

WITH periods AS (
    SELECT
        customer_id,
        start_date,
        -- latest end date among all of this customer's periods that start earlier;
        -- an open period (NULL end) counts as "forever"
        MAX(COALESCE(end_date, '9999-12-31')) OVER (
            PARTITION BY customer_id
            ORDER BY start_date, id
            ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
        ) AS covered_until
    FROM subscription_periods
),
gaps AS (
    SELECT
        customer_id,
        covered_until AS last_access,
        start_date    AS came_back,
        CAST(julianday(start_date) - julianday(covered_until) AS INTEGER) - 1 AS days_without
    FROM periods
)
SELECT
    c.name          AS name,
    g.last_access   AS last_access,
    g.came_back     AS came_back,
    g.days_without  AS days_without
FROM gaps AS g
JOIN customers AS c ON c.id = g.customer_id
WHERE g.days_without >= 1
ORDER BY c.name, g.came_back, c.id;

How it works:

  • periods walks each customer's periods in start-date order (ids are only a tie-breaker, never a proxy for time). For every period it computes covered_until: the latest end_date of all periods that started before it. Because it's a running maximum and not just the previous row's end, a short period nested inside a long one can't fake a gap — the long one's end still wins. A NULL end is replaced by '9999-12-31', so once a customer has an open period, nothing that starts after it can be a gap.
  • The frame ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING excludes the current row. For the first period of each customer the frame is empty, so covered_until is NULL and the row drops out later — no gap before the first period.
  • gaps turns that into the gap: coverage ends on covered_until, access resumes on start_date, and the days in between are (start − covered_until) − 1. Touching periods (start = end + 1) give 0, overlapping or nested periods give a negative number, so days_without >= 1 keeps only real gaps. Only the period that comes back produces a row, which is why a trailing period — active or not — never yields one, and customers who left for good aren't listed.
  • The final ORDER BY uses c.id, not name, as the last key, so namesakes are kept apart. name sorts with SQLite's default byte-order collation; if you want case-insensitive ordering, use ORDER BY c.name COLLATE NOCASE, ....
  • Floor0/2, 0% passed
  • Middle0/2, 0% passed
  • Top0/3, 0% passed
0 of 7 checks passedfloor 0/2, middle 0/2, top 0/3
  • Missed.
    The query runs and returns the four named columnstest group failed
    Floor Tested
  • Missed.
    It gets the example in the question righttest group failed
    Floor Tested
  • Missed.
    Periods that touch or overlap are not counted as gapstest group failed
    Middle Tested
  • Missed.
    Several gaps per customer, same-name customers, sort ordertest group failed
    Middle Tested
  • Missed.
    A period inside a longer one does not hide or invent a gaptest group failed
    Top Tested
  • Missed.
    Open-ended (NULL end) periods never produce a gap after themtest group failed
    Top Tested
  • Missed.
    Days without access is exact across month, leap and year endstest group failed
    Top Tested
  • Took 1 min 56 s.
  • First words after 1 min 47 s.
  • Wrote 9,341 tokens, 8,412 of them thinking.
  • 48¢ at API prices.
All numbers.
Time to finish1 min 56 s
Time to first words1 min 47 s
Input tokens1,683
Cached tokens0
Output tokens9,341
Thinking tokens8,412
Words in the answer366
Price at API rates48¢
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 Fable 5.1 · low effort · 2 Oct 2026

9 of 9

import csv
import io


_NAME_HEADERS = {"name", "full name", "contact name"}
_EMAIL_HEADERS = {"email", "e-mail", "email address", "e-mail address"}
_PHONE_HEADERS = {"phone", "phone number", "mobile", "mobile phone", "telephone"}


def _read_text(path):
    """Read the file as UTF-8 (dropping a BOM), falling back to Windows-1252."""
    with open(path, "rb") as f:
        data = f.read()
    try:
        return data.decode("utf-8-sig")
    except UnicodeDecodeError:
        return data.decode("cp1252")


def _normalize_header(cell):
    return " ".join(cell.strip().lower().split())


def _detect_delimiter(text):
    """Parse the header line with each candidate and keep the one giving more columns."""
    header_line = text.splitlines()[0] if text else ""
    best, best_count = ",", -1
    for delim in (",", ";"):
        count = len(next(csv.reader([header_line], delimiter=delim), []))
        if count > best_count:
            best, best_count = delim, count
    return best


def _clean(value):
    return value.replace("\r\n", "\n").replace("\r", "\n").strip()


def import_contacts(path):
    text = _read_text(path)
    if not text.strip():
        return []

    delimiter = _detect_delimiter(text)
    # newline="" keeps line endings intact so csv can handle breaks inside quoted fields.
    reader = csv.reader(io.StringIO(text, newline=""), delimiter=delimiter)

    header = next(reader, None)
    if header is None:
        return []

    name_idx = email_idx = phone_idx = None
    for i, cell in enumerate(header):
        key = _normalize_header(cell)
        if key in _NAME_HEADERS and name_idx is None:
            name_idx = i
        elif key in _EMAIL_HEADERS and email_idx is None:
            email_idx = i
        elif key in _PHONE_HEADERS and phone_idx is None:
            phone_idx = i

    if email_idx is None:
        return []  # no email column means no row can have an email

    def cell(row, idx):
        if idx is None or idx >= len(row):
            return ""
        return _clean(row[idx])

    contacts = []
    seen = set()
    for row in reader:
        email = cell(row, email_idx).lower()
        if not email or email in seen:
            continue
        seen.add(email)
        contacts.append({
            "name": cell(row, name_idx),
            "email": email,
            "phone": cell(row, phone_idx),
        })
    return contacts

How it works: the file is read as bytes and decoded as UTF-8 (which also strips a BOM), falling back to Windows-1252 if that fails. The delimiter is chosen by parsing the header line with both , and ; and keeping whichever yields more columns. The csv module then does the real parsing (quotes, doubled quotes, embedded line breaks); header names are normalized (lower-cased, whitespace collapsed) and matched against the accepted aliases to find the three column indexes. Each row is read by index with missing cells treated as empty and extras ignored, every value is stripped with internal line breaks collapsed to \n, the email is lower-cased, and rows with no email or an already-seen email are skipped, so blank or separator-only lines fall out naturally and file 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 22 s.
  • First words after 11 s.
  • Wrote 1,858 tokens, 682 of them thinking.
  • 11¢ at API prices.
All numbers.
Time to finish22 s
Time to first words11 s
Input tokens1,395
Cached tokens0
Output tokens1,858
Thinking tokens682
Words in the answer408
Price at API rates11¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

9 of 9

import codecs
import csv
import io

# normalised header text -> key in the result dicts
_COLUMNS = {
    "name": "name", "full name": "name", "contact name": "name",
    "email": "email", "e-mail": "email",
    "email address": "email", "e-mail address": "email",
    "phone": "phone", "phone number": "phone", "mobile": "phone",
    "mobile phone": "phone", "telephone": "phone",
}


def import_contacts(path):
    """Read a contacts CSV and return a list of {"name", "email", "phone"} dicts."""
    with open(path, "rb") as f:
        raw = f.read()
    text = _decode(raw)
    # One kind of line break everywhere, so a break inside a quoted field
    # comes out as a single "\n".
    text = text.replace("\r\n", "\n").replace("\r", "\n")

    reader = csv.reader(io.StringIO(text),
                        delimiter=_detect_delimiter(text),
                        skipinitialspace=True)
    columns = None   # result key -> column index, filled from the header row
    seen = set()
    contacts = []
    for row in reader:
        cells = [cell.strip() for cell in row]
        if not any(cells):           # blank line, or only spaces and separators
            continue
        if columns is None:          # first non-blank row is the header
            columns = {}
            for index, cell in enumerate(cells):
                key = _COLUMNS.get(" ".join(cell.split()).lower())
                if key is not None and key not in columns:
                    columns[key] = index
            continue
        # No recognised e-mail column means every row is skipped -> [].
        email = _cell(cells, columns.get("email")).lower()
        if not email or email in seen:
            continue
        seen.add(email)
        contacts.append({
            "name": _cell(cells, columns.get("name")),
            "email": email,
            "phone": _cell(cells, columns.get("phone")),
        })
    return contacts


def _decode(raw):
    """UTF-8 (with or without BOM), falling back to Windows-1252."""
    if raw.startswith(codecs.BOM_UTF8):
        raw = raw[len(codecs.BOM_UTF8):]
    try:
        return raw.decode("utf-8")
    except UnicodeDecodeError:
        return raw.decode("cp1252", errors="replace")


def _detect_delimiter(text):
    """Comma or semicolon, whichever occurs more often outside quotes
    on the first non-blank line (the header)."""
    header = next((line for line in io.StringIO(text) if line.strip()), "")
    counts = {",": 0, ";": 0}
    in_quotes = False
    for ch in header:
        if ch == '"':
            in_quotes = not in_quotes
        elif ch in counts and not in_quotes:
            counts[ch] += 1
    return ";" if counts[";"] > counts[","] else ","


def _cell(cells, index):
    """Cell at index, or "" if there is no such column or the row is short."""
    if index is None or index >= len(cells):
        return ""
    return cells[index]

How it works:

  • The file is read as bytes, a UTF‑8 BOM is dropped, it's decoded as UTF‑8 and, if that fails, as Windows‑1252; then every \r\n/\r is turned into \n, which is why a line break inside a quoted field comes back as a single \n.
  • The separator is whichever of ,/; shows up more often outside quotes on the first non-blank line; csv.reader then does the real parsing (quoted fields, doubled quotes, multi-line fields, skipinitialspace so a, "b;c" still works).
  • The first non-blank row is the header: each cell is stripped, lower-cased and looked up in _COLUMNS to get the index of the name/email/phone column. Every later row is stripped cell by cell; blank rows, rows without an email and repeated emails (lower-cased, first one wins) are skipped, and a missing column or a short row just gives "". An empty or header-only file naturally yields [].
  • 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 4 min 44 s.
  • First words after 4 min 33 s.
  • Wrote 24,675 tokens, 23,279 of them thinking.
  • $1.25 at API prices.
All numbers.
Time to finish4 min 44 s
Time to first words4 min 33 s
Input tokens1,395
Cached tokens0
Output tokens24,675
Thinking tokens23,279
Words in the answer500
Price at API rates$1.25
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 Fable 5.1 · low effort · 2 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
    """
    # 1. Group invoices by (customer, amount) and sort each group by (due, id).
    #    Only candidates that can possibly match a payment live in the same group,
    #    and the sort order is exactly the "earliest due, then lowest id" preference.
    groups = {}
    for inv in invoices:
        groups.setdefault((inv["customer"], inv["amount"]), []).append(inv)

    tables = {}        # key -> (dues, ids, nxt)
    id_positions = {}  # invoice id -> [(nxt, index), ...] so a used id can be retired everywhere
    for key, group in groups.items():
        group.sort(key=lambda inv: (inv["due"], inv["id"]))  # sorts our own list, not the caller's
        dues = [inv["due"] for inv in group]
        ids = [inv["id"] for inv in group]
        # nxt[i] points towards the first still-unused index >= i (index len(group) is a sentinel).
        nxt = list(range(len(group) + 1))
        tables[key] = (dues, ids, nxt)
        for i, iid in enumerate(ids):
            id_positions.setdefault(iid, []).append((nxt, i))

    def first_unused(nxt, i):
        # Union-find style "skip pointer" with path compression: amortised near O(1).
        root = i
        while nxt[root] != root:
            root = nxt[root]
        while nxt[i] != root:
            nxt[i], i = root, nxt[i]
        return root

    week = timedelta(days=7)
    matches = []
    unmatched = []

    for pay in payments:
        table = tables.get((pay["customer"], pay["amount"]))
        if table is None:
            unmatched.append(pay["id"])
            continue
        dues, ids, nxt = table
        date = pay["date"]
        # Earliest invoice due on or after date-7, skipping ones already used.
        i = first_unused(nxt, bisect_left(dues, date - week))
        if i < len(dues) and dues[i] <= date + week:
            inv_id = ids[i]
            matches.append((pay["id"], inv_id))
            # Retire this invoice id (every position carrying it, as the original's
            # id-based `used` set did), by pointing it past itself.
            for n, j in id_positions[inv_id]:
                n[j] = j + 1
        else:
            unmatched.append(pay["id"])

    return matches, unmatched

Why the old one was slow

For every payment it scanned the whole invoice list, so the work was payments × invoices ≈ 200,000 × 200,000 = 40 billion dictionary lookups and comparisons. That's the ten-minutes-plus you saw; the per-invoice checks themselves are cheap, there are just far too many of them.

What changed

  • Invoices are bucketed once by (customer, amount), so a payment only ever looks at invoices that could possibly match it.
  • Each bucket is sorted by (due, id) — the same ordering the original used to pick a winner — so the best candidate is simply the first unused invoice whose due date is ≥ payment date − 7. A bisect finds that start point, and one comparison against payment date + 7 decides whether it's in the window.
  • The "already used" problem is handled with a skip-pointer (union-find) array instead of re-scanning. This matters for your big customer with a thousand-plus invoices at the same amount and overlapping dates: once invoices get used up, a naive scan from the bisect position would crawl through all the used ones every time and degrade back towards quadratic; the skip pointers jump straight past them in amortised near-constant time.
  • Semantics are preserved exactly: same tie-break (sorted order), same inclusive 7-day window (date−7 ≤ due ≤ date+7 is identical to abs(days) ≤ 7 for dates), payments processed in the given order, and an invoice id is retired everywhere it appears, matching the original's id-based used set even if the invoice list contains a duplicated id. The input lists and dicts are never modified — the sort happens on new per-bucket lists.

Overall cost is roughly O((P + I) log I) instead of O(P × I); on 200k/200k this runs in a second or two in plain Python.

  • 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 44 s.
  • First words after 25 s.
  • Wrote 3,373 tokens, 1,624 of them thinking.
  • 18¢ at API prices.
All numbers.
Time to finish44 s
Time to first words25 s
Input tokens1,562
Cached tokens0
Output tokens3,373
Thinking tokens1,624
Words in the answer736
Price at API rates18¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

7 of 7

from bisect import bisect_left
from collections import defaultdict
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
    """
    # Index the invoices once: one bucket per (customer, amount), sorted by
    # (due, id). Within a bucket, the invoice the old code would pick is simply
    # the first unused entry whose due date lies in the payment's window.
    # Only new lists are built; the caller's lists and dicts are not touched.
    grouped = defaultdict(list)
    for inv in invoices:
        grouped[(inv["customer"], inv["amount"])].append((inv["due"], inv["id"]))

    buckets = {}
    for key, entries in grouped.items():
        entries.sort()  # by due date, then by id (same tie-break as before)
        buckets[key] = (
            [due for due, _ in entries],        # sorted due dates, for bisect
            [inv_id for _, inv_id in entries],  # ids in the same order
            list(range(len(entries) + 1)),      # skip[i]: where to start looking for an unused entry >= i
        )

    def first_unused(skip, i):
        # Smallest index >= i that is still available. A used entry points past
        # itself; path compression keeps repeated searches effectively O(1).
        root = i
        while skip[root] != root:
            root = skip[root]
        while i != root:
            nxt = skip[i]
            skip[i] = root
            i = nxt
        return root

    seven_days = timedelta(days=7)
    eight_days = timedelta(days=8)
    used = set()  # ids already matched -- same rule as the old code
    matches = []
    unmatched = []
    for pay in payments:
        bucket = buckets.get((pay["customer"], pay["amount"]))
        if bucket is not None:
            dues, ids, skip = bucket
            pay_date = pay["date"]
            # abs((due - date).days) <= 7  <=>  date - 7 days <= due < date + 8 days
            lo = bisect_left(dues, pay_date - seven_days)  # first due >= date - 7
            hi = bisect_left(dues, pay_date + eight_days)  # first due past date + 7
            i = first_unused(skip, lo)
            while i < hi and ids[i] in used:
                # Only reachable if an id occurs more than once: the old code
                # treats every invoice carrying an already-matched id as used.
                skip[i] = i + 1
                i = first_unused(skip, i + 1)
            if i < hi:
                skip[i] = i + 1
                used.add(ids[i])
                matches.append((pay["id"], ids[i]))
                continue
        unmatched.append(pay["id"])
    return matches, unmatched

What made the old one slow

It is a nested loop: for every payment it rescans the entire invoice list, so the work is payments × invoices ≈ 200,000 × 200,000 = 40 billion dict lookups and comparisons. Python does on the order of ten million of those per second, so the real run would take hours, not minutes. The used check doesn't reduce that; every invoice is still visited for every payment.

What changed

  1. Index once, then look up. Invoices are grouped into buckets keyed by (customer, amount) and each bucket is sorted by (due, id). A payment now does one dict lookup to get its bucket and two binary searches (bisect) to get the slice of invoices due within ±7 days. Because the slice is sorted by (due, id), the first unused entry in it is exactly the invoice the old code chose: earliest due date, lowest id on a tie.
  2. Skip pointers for used invoices. Simply scanning forward past used entries would creep back toward quadratic for your big customer (thousands of same-amount invoices a few days apart, consumed in bank order rather than date order). So each bucket keeps a skip array: a used entry points past itself, and first_unused follows and compresses those pointers (a small union-find). Finding the first free invoice in a window stays effectively constant time however many used ones sit in front of it.

Total cost is roughly O((P + I) log I) instead of O(P × I); at 200k/200k it should run in about a second.

Behaviour preserved: same name and arguments; payments handled in the order given; the window test is the same set of dates as the original abs((due - date).days) <= 7 (7 days apart still counts); earliest due then lowest id; the used set is kept so an id that has already been matched blocks every invoice with that id, exactly as before (with unique ids that branch never runs); the input lists and dicts are not modified — the sort happens on freshly built lists. The one new requirement is that customer and amount are hashable (strings and integer cents are), since they form the dict key.

Before swapping it in, it is worth running both versions over a few thousand small random inputs (a handful of customers and amounts, dates clustered within a few weeks so collisions happen) and asserting the outputs are identical.

  • 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 5 min 21 s.
  • First words after 5 min 5 s.
  • Wrote 26,738 tokens, 24,783 of them thinking.
  • $1.35 at API prices.
All numbers.
Time to finish5 min 21 s
Time to first words5 min 5 s
Input tokens1,562
Cached tokens0
Output tokens26,738
Thinking tokens24,783
Words in the answer893
Price at API rates$1.35

Planning and architecture

A01Plan a booking app for a dog groomerLow 9/9Max 0/9Max −9

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.

Max effort passed 9 fewer checks than low.

AI answer from Fable 5.1 · low effort · 2 Oct 2026

9 of 9

Booking system plan for a one-person dog groomer

Stack

Keep it boring and hostable for a few dollars a month.

  • Backend: Python + Django. You get admin, auth, migrations, ORM, forms and a database-backed task scheduler for free. Django admin alone covers most of "she runs it from her phone" if you tweak the templates to be mobile-friendly.
  • Database: PostgreSQL (SQLite is fine for development, but use Postgres in production so you can use a transaction with row locking for double-booking protection).
  • Frontend: Server-rendered Django templates with a small amount of JavaScript for the date/slot picker (vanilla or HTMX). No React: a booking page is a form, and you'll build faster on weekends without a separate build pipeline.
  • Payments: Stripe Checkout. It handles card forms, 3-D Secure and PCI for you; refunds are one API call. Create the deposit as a normal Checkout session for 20% of the price.
  • Email/SMS: Postmark or SendGrid for email; Twilio for SMS if she wants text reminders (clients forget email; dog owners answer texts). Start with email only.
  • Scheduled jobs: A cron job (or Django-Q/Celery beat if you prefer) running every 15 minutes for reminders and cleanups. Cron is enough.
  • Hosting: One small VPS (Hetzner/DigitalOcean) or a platform like Fly.io/Railway with managed Postgres. Daily database backups: one-person businesses lose everything when the disk dies.
  • Timezone rule: store every timestamp in UTC in the database. Keep the business's IANA timezone (e.g. Europe/London, America/Chicago) as a single setting, and convert at the edges: when generating slots and when displaying anything to her or a client.

Data model

Service
  name, duration_minutes (30/60/90), price_cents, active

Client
  name, email, phone, notes

Dog
  client → Client, name, breed, size (small/medium/large), notes
  (a client can have several dogs; large size doubles duration)

Appointment
  client, dog, service
  starts_at (UTC), ends_at (UTC)        ← computed: service duration × 2 if large dog
  status: pending_payment / confirmed / cancelled_by_client /
          cancelled_by_business / completed / no_show
  deposit_cents, stripe_checkout_id, stripe_payment_intent_id
  deposit_status: unpaid / paid / refunded / forfeited
  reminder_sent_at (nullable)
  created_at, cancelled_at

AvailabilityRule
  weekday (Tue–Sat), opens_at (local time), closes_at (local time)
  break_start, break_end
  (seed with her hours; editable later without code changes)

BlockedPeriod
  starts_at, ends_at, reason        ← holidays, sick days, half days

Settings (single row)
  business_timezone, deposit_percent (20), booking_horizon_days (42),
  free_cancel_hours (24), slot_step_minutes (e.g. 30)

Key design decisions:

  • Duration lives on the appointment, not just the service, because large dogs change it. Compute ends_at at booking time and never re-derive it.
  • Availability is computed, not stored. For a given day: take the rule for that weekday in local time, subtract lunch, subtract blocked periods, subtract existing confirmed/pending appointments, then slide a window of the requested duration across what's left in slot_step increments. Do this in local time, then convert the candidate start times to UTC.
  • Pending appointments hold the slot for, say, 15 minutes while the client pays. Cron expires unpaid pendings so abandoned checkouts don't block the diary.

Phases

Phase 1 – The diary (weekend 1–2). Models, availability calculation, and a Django admin she can use on her phone to add/edit/cancel appointments and block days. No public booking yet. She can start using it as a replacement for a paper diary straight away, and you'll find out quickly whether the slot logic matches how she actually works. Write unit tests for the availability function now; it's the one piece that will bite you later.

Phase 2 – Public booking (weekend 3–4). Public page: pick service → dog size → date (next six weeks, closed days greyed out) → available start time → contact details. Create a pending_payment appointment inside a database transaction with SELECT ... FOR UPDATE on overlapping appointments so two people can't take the same slot. Redirect to Stripe Checkout. On the webhook checkout.session.completed, mark it confirmed and send a confirmation email with a cancellation link.

Phase 3 – Cancellations and refunds (weekend 5). Signed cancellation link (token in the URL, no login needed). If now < starts_at − 24h, refund the deposit via Stripe and mark refunded; otherwise mark forfeited. Give her a button to cancel from her side that always refunds (she cancelled, not the client). Show the policy clearly on the booking page and in the confirmation email.

Phase 4 – Reminders (weekend 6). Cron every 15 minutes: find confirmed appointments where starts_at is between 24 and 24.25 hours from now and reminder_sent_at is null; send; stamp. Idempotent by design, so a crashed run just sends on the next pass.

Phase 5 – Polish (ongoing). "Today" view for her phone, client history, SMS reminders, no-show tracking, a simple pricing page. Resist building a client login; the signed link is enough.

What could go wrong

  • Daylight saving. "9:00 Tuesday" means different UTC offsets in March and October. Always build slots in local time with the real timezone (zoneinfo), then convert. Never add 24 hours to a datetime to get "tomorrow at the same time"; add one calendar day in local time. Test the two changeover weekends explicitly.
  • Double booking. Two clients click the same slot at once. The transaction-plus-lock in Phase 2 is the fix; don't rely on "check then insert" without it.
  • Lunch and large dogs. A 90-minute service for a large dog is three hours, so it won't fit between 9:00 and 12:30. Your slot generator must handle "no slots today" gracefully, and she needs to decide whether a large-dog groom may span lunch. Ask her before you write the code.
  • Stripe webhooks. If the webhook fails, the client has paid but the appointment stays pending and the cron expires it. Verify the webhook signature, make the handler idempotent, and add a nightly reconciliation that checks pending appointments against Stripe.
  • Refund edge cases. Refunds can fail (card closed) or be partially disputed. Log every Stripe response, and give her a manual "mark as refunded" option.
  • Email in spam. Set up SPF/DKIM on her domain from day one, or reminders will go nowhere.
  • Her phone is the whole business. Make the admin usable one-handed with big buttons; test it on her actual phone, not your desktop browser.
  • Scope creep. She'll ask for packages, waiting lists and loyalty points by week three. Ship the diary first; everything else can wait until she's taken real bookings for a month.
  • 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.
    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
  • Passed.
    The first phase is small and usable before payments exist
    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 39 s.
  • First words after 2.4 s.
  • Wrote 2,528 tokens, 38 of them thinking.
  • 14¢ at API prices.
All numbers.
Time to finish39 s
Time to first words2.4 s
Input tokens1,046
Cached tokens0
Output tokens2,528
Thinking tokens38
Words in the answer1,062
Price at API rates14¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

0 of 9

The answer was cut off at the token limit.

  • Floor0/2, 0% passed
  • Middle0/4, 0% passed
  • Top0/3, 0% passed
0 of 9 checks passedfloor 0/2, middle 0/4, top 0/3
  • Missed.
    Covers all four parts asked for: stack, data model, phases, risksNo answer
    Floor Counted
  • Missed.
    The data model has bookings, services and clients or dogsNo answer
    Floor Counted
  • Missed.
    Stays under the 1,200-word limitNo answer
    Middle Counted
  • Missed.
    Stops double bookings in the database, not only in the pageNo answer
    Middle Read
  • Missed.
    Takes the deposit through a hosted checkout and never stores card numbersNo answer
    Middle Read
  • Missed.
    The first phase is small and usable before payments existNo answer
    Middle Read
  • Missed.
    Handles time zones and daylight saving when storing timesNo answer
    Top Read
  • Missed.
    Reminders come from a scheduled job that cannot send twiceNo answer
    Top Read
  • Missed.
    Applies the 24-hour refund rule from the appointment start timeNo answer
    Top Read
  • Took 5 min 47 s.
  • Wrote 32,000 tokens, 31,999 of them thinking.
  • $1.61 at API prices.
All numbers.
Time to finish5 min 47 s
Time to first wordsnot recorded
Input tokens1,046
Cached tokens0
Output tokens32,000
Thinking tokens31,999
Words in the answer0
Price at API rates$1.61
A02Should we switch to microservices?Low 7/8Max 7/8Same

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.

Both settings passed 7 of 8 checks.

AI answer from Fable 5.1 · low effort · 2 Oct 2026

7 of 8

Don't split into microservices. Six services on Kubernetes won't fix either of your real problems, and with two engineers it will make both of them worse: you'd trade a 20-minute deploy for six pipelines, a cluster to operate, and network calls where you currently have function calls. Fix the two actual problems directly; both are probably a week or two of work combined.

The 20-minute deploy. Profile the pipeline and find where the time goes. Usual culprits: a test suite that's slow or run serially, Docker images rebuilt from scratch with no layer caching, migrations or collectstatic running inefficiently, or a deploy process that's manual. Parallelize tests, cache dependencies and image layers, and get to a single-command deploy. A Django monolith with 200 customers should deploy in under five minutes. Splitting the codebase doesn't make a slow pipeline fast; it gives you six slow pipelines.

The report timeout. This is almost certainly a database problem, not an architecture problem. Pull the SQL for that page for one of the three big customers and look at the query plan. Likely suspects: N+1 queries from the ORM, missing indexes on the columns you filter and group by, or aggregating over raw rows every request. Fixes, in order of cheapness: select_related/prefetch_related, indexes, pushing aggregation into the database, then precomputing the report nightly (or on a Celery job) into a summary table. Microservices would leave this exact query exactly as slow, plus add latency.

On the funding round. Investors fund growth, retention, and a team that ships. "We run Django and Postgres and deploy ten times a day" is a stronger story than "we spent a quarter rebuilding infrastructure for scale we don't have yet." Companies much larger than you run on monoliths. The time to carve out a service is when a specific piece has a clearly different scaling or deployment need and you have the people to own it. You're not there.

If the monolith is genuinely messy, the right intermediate step is modularizing inside it: clear app boundaries, no cross-imports into model internals. That gives you most of the organizational benefit and keeps the option to extract later.

  • Floor1/2, 50% passed
  • Middle2/2, 100% passed
  • Top4/4, 100% passed
7 of 8 checks passedfloor 1/2, middle 2/2, top 4/4
  • Passed.
    Says up front: not now, keep the monolith
    Floor Counted
  • Missed.
    Stays under the 350-word limit357 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
  • 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 13 s.
  • First words after 2.7 s.
  • Wrote 710 tokens.
  • 4.4¢ at API prices.
All numbers.
Time to finish13 s
Time to first words2.7 s
Input tokens876
Cached tokens0
Output tokens710
Thinking tokens0
Words in the answer357
Price at API rates4.4¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

7 of 8

Don't do it: with two engineers and 200 customers, splitting into six microservices would make both of your real problems worse, not better. Microservices solve an organizational problem (many teams stepping on each other's deploys) that you don't have, and they carry an operational tax that two people can't afford.

Why it won't help the actual problems:

  • Slow deploys. Six services means six pipelines, six images, and API contracts to version between them. Deploys get slower and riskier. Your 20 minutes is almost certainly a slow test suite, an uncached Docker build, or long migrations. Profile the pipeline, parallelize the tests, cache image layers. That's about a week of work to get under five minutes.
  • Report timeouts. This is a query problem, and microservices make it worse because the report would have to assemble data over the network from several databases. Run the page with Django Debug Toolbar or EXPLAIN ANALYZE. The usual culprits: N+1 queries (fix with select_related/prefetch_related), missing indexes, aggregating in Python instead of SQL. If the report is genuinely heavy, generate it in a background job (Celery) or a nightly rollup table and serve the cached result. A few days of work.

On the funding round: investors fund growth and retention, not architecture diagrams. "Two engineers serve 200 paying customers on a boring, reliable stack" is a strength in diligence, not a weakness. A six-month replatform that stalls feature work right before a raise is a real risk. If a technical diligence partner asks how you scale, the honest answer is that Postgres plus caching, read replicas, and background workers has 100x headroom from here. Shopify, Basecamp, and Stack Overflow run monoliths at scales you won't see for years.

When to revisit: when you have several teams blocking each other on deploys, or one component with wildly different scaling or language needs. Neither is true today. Fix the two real problems in the next two weeks, then get back to shipping features.

  • Floor2/2, 100% passed
  • Middle2/2, 100% passed
  • Top3/4, 75% passed
7 of 8 checks passedfloor 2/2, middle 2/2, top 3/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
  • Missed.
    Suggests a middle path: clear modules inside the monolithIt never suggests a modular monolith or setting module boundaries so a part can be split out later; it only says to kee…
    Top Read
  • Passed.
    Tone is fair to the cofounder and safe to forward
    Top Read
  • Took 49 s.
  • First words after 44 s.
  • Wrote 3,691 tokens, 3,041 of them thinking.
  • 19¢ at API prices.
All numbers.
Time to finish49 s
Time to first words44 s
Input tokens876
Cached tokens0
Output tokens3,691
Thinking tokens3,041
Words in the answer327
Price at API rates19¢
A03Rename a column with no downtimeLow 8/9Max 6/9Max −2

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 2 fewer checks than low.

AI answer from Fable 5.1 · low effort · 2 Oct 2026

8 of 9

Here's the plan. The core idea is expand → migrate → contract: add handle alongside username, keep them in sync with a trigger, move the three services over one at a time, then drop username only when nothing references it. A straight ALTER TABLE ... RENAME COLUMN is instant in Postgres but breaks every service still reading username the moment it commits, which is why you can't do it directly with three independently deployed consumers.

Run every DDL statement with a short lock timeout so a long-running query can't make you queue behind it and block the whole table:

SET lock_timeout = '3s';
SET statement_timeout = '0';  -- for the sessions doing DDL; batch updates get their own timeout

If a DDL statement fails on the lock timeout, just retry it; nothing is left half-done.


1. Add the new column (nullable, no default)

ALTER TABLE users ADD COLUMN handle text;

Metadata-only in PG16, takes an ACCESS EXCLUSIVE lock for milliseconds. Don't add NOT NULL or a DEFAULT yet.

Way back: ALTER TABLE users DROP COLUMN handle; — nothing reads it yet, so this is free.

2. Install a sync trigger so new/changed rows have both columns filled

CREATE OR REPLACE FUNCTION users_sync_username_handle() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    NEW.handle   := COALESCE(NEW.handle, NEW.username);
    NEW.username := COALESCE(NEW.username, NEW.handle);
  ELSE  -- UPDATE
    IF NEW.handle IS DISTINCT FROM OLD.handle THEN
      NEW.username := NEW.handle;          -- writer moved to handle
    ELSIF NEW.username IS DISTINCT FROM OLD.username THEN
      NEW.handle := NEW.username;          -- writer still on username
    ELSIF NEW.handle IS NULL THEN
      NEW.handle := NEW.username;          -- row not yet backfilled
    END IF;
  END IF;
  RETURN NEW;
END $$;

CREATE TRIGGER users_sync_username_handle
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION users_sync_username_handle();

Because it's a BEFORE trigger that mutates NEW, there's no second write and no recursion. It works in both directions, so you can flip the API from writing username to writing handle at any point later without a coordinated deploy.

Way back: DROP TRIGGER users_sync_username_handle ON users; DROP FUNCTION users_sync_username_handle(); then step 1's rollback.

3. Backfill existing rows in batches

Do this from a script, not one giant UPDATE (a single 40M-row update would hold locks, bloat the table, and be a nightmare to abort). Batch by primary key:

-- repeat, advancing the id range; keep batches ~5k–20k rows and sleep briefly between them
UPDATE users
SET    handle = username
WHERE  id >= :lo AND id < :hi
AND    handle IS NULL;

Note the trigger from step 2 also fills handle on any row the app touches, so the backfill and live traffic can't conflict in a way that leaves a row behind. Watch replication lag and autovacuum while this runs; pause if lag climbs. Each UPDATE writes a new row version, so expect roughly a table's worth of dead tuples over the run — make sure autovacuum keeps up or run a manual VACUUM users afterward.

Check when done:

SELECT count(*) FROM users WHERE handle IS NULL;  -- must be 0
SELECT count(*) FROM users WHERE handle <> username;  -- must be 0

Way back: Stop the script. Partial backfill is harmless; nothing depends on handle yet. Undo steps 2 and 1 if you're abandoning.

4. Build the unique index, concurrently

CREATE UNIQUE INDEX CONCURRENTLY users_handle_key ON users (handle);

No write lock. On a 40M-row table this takes a while — fine. Afterwards verify it's valid:

SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE indexrelid = 'users_handle_key'::regclass;

If indisvalid is false (the build was cancelled or hit a duplicate), DROP INDEX CONCURRENTLY users_handle_key; and retry. Duplicates shouldn't exist since handle = username and username is already unique, but the check is cheap.

Way back: DROP INDEX CONCURRENTLY users_handle_key;

5. Add NOT NULL without a full-table lock scan

Add it as a non-validated check constraint first, validate it (SHARE UPDATE EXCLUSIVE lock only — writes continue), then let PG16 use the validated constraint to prove NOT NULL without rescanning:

ALTER TABLE users ADD CONSTRAINT users_handle_not_null CHECK (handle IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_handle_not_null;   -- scans, but doesn't block writes
ALTER TABLE users ALTER COLUMN handle SET NOT NULL;            -- instant: uses the validated check
ALTER TABLE users DROP CONSTRAINT users_handle_not_null;

Way back: ALTER TABLE users ALTER COLUMN handle DROP NOT NULL; (and drop the check constraint if you stopped partway).

At this point the database is in a stable, dual-column state. You can sit here as long as you like.

6. Deploy the two read-only services to read handle

Each one is a normal independent deploy: change SELECT ... username to SELECT ... handle (alias it if their code still wants the key named username internally). Because both columns are populated and kept in sync, there's no ordering dependency between the two services.

Way back: Redeploy the previous version. Both columns are still there and correct.

7. Deploy the main API: read handle, write handle, expose both names in JSON

Database side of the API:

  • All SELECTs read handle.
  • All INSERTs/UPDATEs write handle (the trigger mirrors it to username).
  • Any uniqueness-violation handling that matched on users_username_key should now also recognise users_handle_key.

API contract side (this is what protects the old mobile apps):

  • Responses include both "username" and "handle" with the same value.
  • Requests accept either username or handle as input; if both are sent and differ, reject with a 400.
  • Mark username as deprecated in the API docs/OpenAPI spec. Start a three-month clock from the mobile release that switches to handle.

Way back: Redeploy the previous API version. It writes username, the trigger mirrors to handle, the other two services (now on handle) keep working. No data is lost in either direction.

8. Confirm nothing references username any more

Before touching the old column:

  • Grep all three codebases and any ad-hoc reporting/BI/ETL for username.
  • Enable log_statement = 'all' briefly on a replica, or use pg_stat_statements, and search for queries hitting username:
SELECT query, calls FROM pg_stat_statements WHERE query ILIKE '%username%' ORDER BY calls DESC;

Wait at least one full deploy cycle of all three services with zero hits. There's no rush — the dual-column state costs you one extra text column and one extra index.

9. Contract: make username droppable, safely, in stages

Do these as separate steps with a pause between each, so each one is individually reversible.

9a. Drop the sync trigger (API now writes only handle, nothing reads username):

DROP TRIGGER users_sync_username_handle ON users;
DROP FUNCTION users_sync_username_handle();

Way back: Recreate from step 2, then re-run a quick backfill UPDATE users SET username = handle WHERE username IS DISTINCT FROM handle in batches. From this moment username starts drifting, so if you have any doubt, don't proceed.

9b. Drop NOT NULL on the old column — otherwise inserts that no longer set username will fail:

Actually do this before 9a if your API deploy in step 7 stopped sending username entirely. Safer ordering: 9b first, then 9a.

ALTER TABLE users ALTER COLUMN username DROP NOT NULL;

Way back: You can't re-add NOT NULL cheaply once NULLs exist, so re-adding it means a backfill from handle first (step 5's technique).

9c. Drop the old unique index:

DROP INDEX CONCURRENTLY users_username_key;   -- use the actual index name from \d users

Way back: CREATE UNIQUE INDEX CONCURRENTLY ... ON users (username); (only succeeds while values are still unique and non-null, i.e. before 9d).

9d. Drop the column:

ALTER TABLE users DROP COLUMN username;

Instant; the data is just marked dead and reclaimed by subsequent vacuums/rewrites.

Way back: None at the database level. The only recovery is restoring from backup or re-adding the column and backfilling from handle — which is fine, because by now they're identical. This is the one irreversible step, which is why everything else is sequenced before it.

10. Retire the username field from the API response

After the three-month window for old mobile clients has passed (measure it — check request logs by client version/user-agent rather than trusting the calendar), remove "username" from JSON responses and stop accepting it as input. Return a clear 400 for any straggler sending the old key.

Way back: Redeploy the version that emits both. Since the API reads handle internally, re-adding the alias is a one-line change with no database involvement.


A few things that bite people on this exact migration:

  • ORM-generated schema diffs. If any service uses an ORM with migration autogeneration, it'll see the extra column and try to "fix" the schema. Make sure the model definitions are updated to know about handle before each service's deploy, or exclude the column from diffing.
  • Views, functions, RLS policies, foreign keys. \d+ users and pg_depend will show anything bound to username. Views in particular will block DROP COLUMN and need to be recreated.
  • Logical replication / CDC consumers (Debezium, ETL into a warehouse). They see the new column as soon as step 1 commits and lose the old one at 9d; treat them as a fourth "service" in step 6.
  • Don't use RENAME COLUMN as a shortcut at step 9 ("just rename handle back to username" or similar). The expand/contract already got you there; a rename at the end reintroduces exactly the coordination problem you started with.
  • 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 periodThe column drop (9d) comes before step 10's three-month mobile window, so it is not the final step and does not wait fo…
    Top Read
  • Took 1 min 12 s.
  • First words after 18 s.
  • Wrote 4,745 tokens, 835 of them thinking.
  • 25¢ at API prices.
All numbers.
Time to finish1 min 12 s
Time to first words18 s
Input tokens926
Cached tokens0
Output tokens4,745
Thinking tokens835
Words in the answer1,570
Price at API rates25¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

6 of 9

The answer was cut off at the token limit.

Two separate contracts are tangled together here: the column name (internal — your three services) and the JSON field name (external — mobile clients you don't control). Treat them independently. Database side: expand/contract — add handle, keep it in lockstep with username via a trigger, move each service over on its own schedule, then remove username. API side: keep username as a JSON alias for at least three months, possibly forever.

Why not a bare ALTER TABLE ... RENAME COLUMN? It's instant, but it can't be atomic with three separate deploys — whichever service hasn't shipped yet starts erroring the moment it lands. The extra column is what buys you independent deploys and an undo at every step.

Conventions for everything below

  • Every ALTER TABLE / CREATE TRIGGER here is catalog-only (milliseconds) once it has its lock, but while it waits for the lock, every new query on users queues behind it. So always run DDL as BEGIN; SET LOCAL lock_timeout = '1s'; ...; COMMIT; inside a retry loop (until psql -v ON_ERROR_STOP=1 -f step.sql; do sleep 1; done). If something holds long transactions on users (reports, batch jobs), it will keep timing you out — find it first.
  • Long non-blocking work (backfill, index build, validate) runs in a session with SET statement_timeout = 0;.
  • Assumes users.id is an integer/bigint primary key (for batching). If it's a UUID, the backfill loop needs a different driver.

Pre-flight

1. Inventory what touches username.

  • Grep all three codebases, plus anything else with DB access (cron jobs, BI tools, dbt, scripts).
  • Backstop: SELECT calls, query FROM pg_stat_statements WHERE query ~* 'username'; — every query shape should belong to something you know about.
  • Check for other DB-side dependents: views, functions, RLS policies, and especially foreign keys referencing users(username) or logical replication / CDC (Debezium etc.) on this table. Either of those changes the plan — flag them.
  • Look for positional INSERT INTO users VALUES (...) (no column list) and SELECT * into fixed-shape structs — adding a column breaks both. Also, SELECT * combined with server-side prepared statements will throw a burst of cached plan must not change result type errors at steps 2 and 11; name your columns or confirm your driver re-prepares cleanly.
  • Disk: you're adding a copy of a text column plus an index, and the backfill temporarily roughly doubles the heap. Have ~2× the current users size free.

Way back: nothing has changed.

Expand

2. Add the column. Nullable with no default — catalog-only in every Postgres version.

BEGIN;
SET LOCAL lock_timeout = '1s';
ALTER TABLE users ADD COLUMN handle text;
COMMIT;

Way back: ALTER TABLE users DROP COLUMN handle;

3. Sync trigger. From here on, both columns are always equal no matter which one the app writes, so services can switch (and switch back) in any order.

CREATE OR REPLACE FUNCTION users_sync_username_handle() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'UPDATE' THEN
    IF NEW.username IS DISTINCT FROM OLD.username THEN
      NEW.handle := NEW.username;        -- old code changed username
    ELSIF NEW.handle IS DISTINCT FROM OLD.handle THEN
      NEW.username := NEW.handle;        -- new code changed handle
    END IF;
  END IF;
  -- fill whichever side is missing (inserts, and rows not yet backfilled)
  IF NEW.handle   IS NULL THEN NEW.handle   := NEW.username; END IF;
  IF NEW.username IS NULL THEN NEW.username := NEW.handle;   END IF;
  RETURN NEW;
END $$;

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

Cost is tens of microseconds per write — invisible at your volume. (App-level dual writes are the alternative; the trigger is safer because it needs no deploy and can't drift.)

Way back: DROP TRIGGER users_sync_username_handle ON users;

4. Backfill in small batches. One short transaction per batch; idempotent, so you can stop it and re-CALL it at any time. Do this before the index exists — one fewer index to maintain across 40M updates.

CREATE PROCEDURE backfill_users_handle(batch_size int, pause_ms int)
LANGUAGE plpgsql AS $$
DECLARE
  lo bigint; max_id bigint;
BEGIN
  SELECT min(id), max(id) INTO lo, max_id FROM users;
  WHILE lo <= max_id LOOP
    UPDATE users SET handle = username
     WHERE id >= lo AND id < lo + batch_size AND handle IS NULL;
    COMMIT;                               -- one short transaction per batch
    PERFORM pg_sleep(pause_ms / 1000.0);  -- holds no locks while sleeping
    lo := lo + batch_size;
  END LOOP;
END $$;

SET statement_timeout = 0;
CALL backfill_users_handle(5000, 50);     -- start small, tune while watching

Watch p99 latency, WAL rate and replica lag; raise the batch size or lower the pause if it's quiet. Rows written during the run are covered by the trigger. Afterwards:

VACUUM (VERBOSE) users;   -- plain VACUUM only. NEVER VACUUM FULL: it
  • Floor2/2, 100% passed
  • Middle2/3, 67% passed
  • Top2/4, 50% passed
6 of 9 checks passedfloor 2/2, middle 2/3, top 2/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
  • Missed.
    Builds the unique index with CREATE INDEX CONCURRENTLYnot found
    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
  • Missed.
    Gives a way back at every stepSteps 1–3 give a way back, but the answer stops during step 4 with no rollback, and it never reaches or names the final…
    Top Read
  • Missed.
    Drops the old column last, after a waiting periodThe answer stops at the backfill; apart from the summary's "then remove username", it has no actual drop step after the…
    Top Read
  • Took 7 min 20 s.
  • First words after 6 min 59 s.
  • Wrote 32,000 tokens, 29,900 of them thinking.
  • $1.61 at API prices.
All numbers.
Time to finish7 min 20 s
Time to first words6 min 59 s
Input tokens926
Cached tokens0
Output tokens32,000
Thinking tokens29,900
Words in the answer797
Price at API rates$1.61
A04Turn a messy feature brief into a specLow 7/8Max 7/8Same

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.

Both settings passed 7 of 8 checks.

AI answer from Fable 5.1 · low effort · 2 Oct 2026

7 of 8

Team Invites v1 — Spec (Quillwharf)

Status: Draft — not ready to hand over until the questions in Section 5 are answered.

Before anything else: the brief contradicts itself in three places, and each one changes what gets built. I've written the spec with placeholders rather than picking a side for you.

TopicBrief says (first)Brief says (later)
Invite expiry7 days, "so old links don't float around"1 month, because agencies go quiet over holidays
Who can inviteAdmins only, to protect client dataAny member, "same as most team tools"
Free plan limit3 seats, owner counts as oneUp to 5 people, then upgrade prompt

Everything below marks these as [DECISION A/B/C] so the developer can see exactly what's blocked.


1. Summary

Let workspace users invite teammates by email from Settings > Team. The invitee receives an emailed link, creates an account or logs in, and lands directly in the workspace with the Member role. Invites can be resent or revoked while pending, expire automatically, respect plan seat limits, and are recorded in an audit trail and analytics.

Why: Support currently adds teammates by hand, costing hours per week. It's the top request on the feedback board.

Target: Launch end of next month. Dev capacity ~3 weeks.

2. Scope

In scope (v1)

  • Invite by email from Settings > Team
  • Invite email from no-reply, subject You've been invited to <workspace name>
  • Pending state in team list; resend (rate-limited) and revoke
  • Link expiry [DECISION A]
  • Invitee accept flow: create account or log in → land in workspace
  • New users get Member role; admins can change it afterwards
  • Seat limits: free plan cap [DECISION C]; paid plans per seat, monthly, extra seats purchasable in Settings > Billing
  • Audit trail and analytics events

Out of scope (v1)

  • Per-workspace setting to control who can invite (noted as "later")
  • Choosing a role at invite time
  • Bulk / CSV invites
  • Invite links not tied to a specific email (shareable links)
  • Anything not listed above — the developer should assume no.

3. User stories

Roles used: Owner (counts as a seat, is also an Admin), Admin, Member, Invitee (person receiving the invite).

Sending invites

US-1 — Send an invite As an [Admin / any Member — DECISION B], I want to enter an email address in Settings > Team and send an invite, so I can add a teammate without contacting support.

US-2 — See pending invites As a workspace user viewing Settings > Team, I want pending invitees listed with a "Pending" status, so I know who has been invited but hasn't joined.

US-3 — Resend an invite As the inviter (or an Admin), I want to resend a pending invite, so the invitee gets a fresh email if they missed or lost the first one.

US-4 — Revoke an invite As an Admin, I want to revoke a pending invite, so a link I sent by mistake or to the wrong person can't be used.

US-5 — Invite respects seat limits As an Admin, I want to be told when I've hit the seat limit and shown how to add seats, so I don't send invites that can't be accepted.

Receiving invites

US-6 — Accept as a new user As an Invitee without a Quillwharf account, I want to click the link, create an account, and land directly in the workspace, so joining is effortless.

US-7 — Accept as an existing user As an Invitee who already has an account, I want to click the link, log in, and land directly in the workspace.

US-8 — Clear handling of dead links As an Invitee, when a link is expired or revoked, I want a plain explanation and a way forward, so I don't have to email support.

Roles and billing

US-9 — Default role As an Admin, I want new joiners to start as Members, so nobody gets elevated access by accident; I can promote them later.

US-10 — Buy seats As an Admin on a paid plan, I want to add seats in Settings > Billing, so I can invite more people when I hit the limit.

US-11 — Free plan upgrade prompt As an Admin on the free plan at the seat cap, I want an upgrade prompt instead of a bare error, so I know how to grow the team.

Oversight

US-12 — Audit trail As an Admin, I want a record of who invited whom, when, and whether it was accepted, so I can account for every person in a workspace holding client data.

US-13 — Analytics As the product team, we want invites sent and invites accepted tracked, so we can measure adoption and conversion.

4. Acceptance criteria

AC-1 Sending (US-1)

  1. Settings > Team shows an "Invite" control to users permitted to invite [DECISION B]; it is hidden or disabled for others, and the API rejects invite requests from unpermitted users (UI hiding alone is not enough).
  2. Email address is validated for format before sending. Invalid → inline error, nothing sent.
  3. Inviting an email that already belongs to a workspace member → error "Already a member", nothing sent.
  4. Inviting an email with an existing pending invite → error offering to resend instead; no duplicate invite is created.
  5. Inviting at or above the seat limit → blocked with the message defined in AC-5. Nothing sent.
  6. On success: invite record created, email sent, invitee appears in the team list as Pending immediately.
  7. Email is sent from the no-reply address with subject exactly You've been invited to <workspace name>. Body includes the inviter's name, the workspace name, the link, and when it expires.

AC-2 Pending list (US-2)

  1. Pending invitees show in Settings > Team with email, status "Pending", who invited them, date sent, and expiry date.
  2. Resend and Revoke actions are available on each pending row to users permitted to use them (see AC-3, AC-4).
  3. Once accepted, the row becomes a normal member row; once revoked or expired, it is removed from the pending list (still visible in the audit trail).

AC-3 Resend (US-3)

  1. Resend is allowed at most once per hour per invite. Within the hour, the action is disabled and shows when it becomes available again.
  2. Resend sends a new email with a new link and resets the expiry window; the previous link stops working.
  3. Resend is recorded in the audit trail.

AC-4 Revoke (US-4)

  1. Revoking marks the invite as revoked; its link is invalid immediately.
  2. Revoking does not consume or hold a seat.
  3. Revoke is recorded in the audit trail.
  4. A revoked email can be re-invited as a new invite.

AC-5 Seat limits (US-5, US-10, US-11)

  1. Seat count = active members (including Owner) + pending invites. (See Q7 — confirm pending invites count against seats.)
  2. Free plan: limit is [DECISION C: 3 or 5], Owner counts as one. When the next invite would exceed it, show the upgrade prompt linking to Settings > Billing. Invite is not sent.
  3. Paid plan: when the next invite would exceed purchased seats, show a message linking to Settings > Billing to add seats. Invite is not sent.
  4. Seats are per seat, billed monthly. Adding seats in Settings > Billing takes effect immediately for the invite check. (Proration and downgrade behaviour: see Q8.)
  5. If seats are reduced below current usage, existing members are not removed in v1; only new invites are blocked. (Confirm in Q8.)

AC-6 Accepting (US-6, US-7)

  1. Link is unguessable (cryptographically random token), single-use, and tied to the invited email.
  2. Valid link, no session → account creation screen, pre-filled and locked to the invited email; on completion the user is a Member of the workspace and lands in it. No additional screens.
  3. Valid link, existing account for that email, no session → login screen; on login, user is added as Member and lands in the workspace.
  4. Valid link, user already logged in as the invited email → added immediately, lands in the workspace.
  5. Valid link, user logged in as a different email → shown a message explaining the invite was sent to a different address, with options to log out and continue or go back. User is not added. (Confirm in Q9.)
  6. Accepting marks the invite as accepted with a timestamp and emits the analytics event.
  7. Accepting after the invite was revoked or has expired → the dead-link page (AC-7), user is not added.

AC-7 Dead links (US-8)

  1. Expired link → page saying the invite has expired and who to contact (the inviter's name/workspace) to get a new one. No support email required.
  2. Revoked link → page saying the invite is no longer valid.
  3. Already-used link → if the user is logged in as that member, redirect into the workspace; otherwise prompt to log in.
  4. Expiry is [DECISION A: 7 days or 30 days] from the most recent send/resend. Expiry is enforced server-side; a scheduled job (or on-read check) transitions invites to Expired.

AC-8 Roles (US-9)

  1. Every accepted invitee is created with the Member role.
  2. Admins can change the role from Settings > Team afterwards using the existing role controls (no new UI needed).

AC-9 Audit trail (US-12)

  1. For each invite, the system stores: inviter user ID, invitee email, workspace ID, created at, each resend timestamp, revoked at + by whom (if any), expired at (if any), accepted at + resulting user ID (if any).
  2. Records are retained even after revoke/expiry/accept and after the member is later removed.
  3. Admins can view this history. (Where — a tab in Settings > Team, or an export? See Q11.)

AC-10 Analytics (US-13)

  1. Event invite_sent with workspace ID, plan, inviter role, and whether it was a resend.
  2. Event invite_accepted with workspace ID, plan, time-to-accept, and whether the invitee was a new or existing account.
  3. Events contain no invitee email in the analytics payload unless explicitly agreed. (See Q12.)

AC-11 Non-functional

  1. Invite email lands within 1 minute of sending under normal conditions.
  2. Tokens are stored hashed, not in plain text.
  3. Permission checks and seat-limit checks are enforced server-side.
  4. All user-facing error states above have copy defined before build. (See Q13.)

5. Questions to answer before handing this over

Blocking — the spec cannot be finished without these

  1. [DECISION A] Expiry: 7 days or 30 days? The brief gives a security reason for 7 and a customer reason for 30. A middle option: 30-day expiry, but a link is invalidated the moment it's resent or revoked, which addresses most of the "old links floating around" concern. Pick one.
  2. [DECISION B] Who can invite: Admins only, or any Member? The brief argues both — client data protection vs growth. Options: (a) Admins only now, member inviting behind a setting later (as the brief hints); (b) any Member now, with the setting later; (c) Members can invite but Admins must approve (bigger build, probably not in 3 weeks). This also determines who can resend/revoke.
  3. [DECISION C] Free plan cap: 3 seats or 5? Both include the Owner. This is also a pricing decision, so Billing should sign off.

Important — affects build effort or behaviour

  1. Does the developer actually have three weeks, and is end of next month the launch date for all customers or a beta? The spec above is a fair amount for three weeks once billing is involved.
  2. Does a seat-based paid plan already exist, with per-seat purchase in Settings > Billing, or is that new work in this project? If new, it probably doubles the scope.
  3. Are there existing concepts of Owner / Admin / Member roles with the permissions assumed here?
  4. Do pending invites count against the seat limit? (Spec assumes yes — otherwise an admin could send 50 invites on a 3-seat plan.)
  5. Billing edge cases: proration when adding a seat mid-month; what happens if an admin reduces seats below current member count; whether removing a member frees a seat immediately.
  6. If someone clicks the link while logged in as a different account, do we let them accept into that account (link becomes effectively transferable) or insist on the invited email? Spec assumes insist.
  7. Can an invitee belong to multiple workspaces with one account? Spec assumes yes.
  8. Where does the audit trail live for Admins — a view in Settings > Team, a CSV export, or just the database for now (support can query it)?
  9. Privacy: is it acceptable to send invitee email addresses to the analytics tool? Also, how long do we retain invite records for people who never accepted?
  10. Who writes the copy for the invite email and the error/dead-link pages?
  11. Should the invite email include who invited them and a short message field? (Suggested yes to the name, no to the message in v1.)
  12. Do we need a "member removed" / "left workspace" flow in v1, or does that already exist?

Nice to confirm

  1. Rate limits beyond resend — e.g. a cap on total invites per workspace per day to prevent spam from a compromised account.
  2. Should Owners be exempt from any limit on the number of Admins, and can an Admin remove the Owner? (Only matters if role changes are touched.)

Once questions 1–3 are decided, replace the [DECISION] markers in Sections 3–4 and the spec is ready to hand over. Questions 4–8 are worth settling in the same conversation, since they set whether three weeks is realistic.

  • Floor2/2, 100% passed
  • Middle2/3, 67% passed
  • Top3/3, 100% passed
7 of 8 checks passedfloor 2/2, middle 2/3, top 3/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
  • Passed.
    Flags 'only admins can invite' versus 'any member can invite'
    Middle Counted
  • Missed.
    Flags the free plan limit of 3 seats versus 5 peopleno passage has all the parts
    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 7 s.
  • First words after 3.0 s.
  • Wrote 4,775 tokens, 99 of them thinking.
  • 25¢ at API prices.
All numbers.
Time to finish1 min 7 s
Time to first words3.0 s
Input tokens1,339
Cached tokens0
Output tokens4,775
Thinking tokens99
Words in the answer2,315
Price at API rates25¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

7 of 8

Here's the spec. One thing to know before you hand it over: the brief contradicts itself in three places — expiry (7 days vs. a month), who can invite (admins only vs. any member), and free-plan seats (3 vs. 5). Rather than guess, I've marked each as D1 / D2 / D3 wherever it changes behaviour and put them at the top of the questions. Settle those three and the rest is a find-and-replace.


Team Invites v1 — Developer Spec (Quillwharf)

Status: Draft. Decisions D1–D3 must be made before build.

1. Goal

Customers add their own teammates to a workspace by email invite, with no support involvement. Success: support stops adding users by hand, and we can see invites sent vs. accepted.

2. Scope

In v1

  • Invite a single email address from Settings > Team
  • Pending invites in the team list, with resend (rate-limited) and revoke
  • Link expiry
  • Accept flow for new and existing accounts, landing straight in the workspace
  • Seat caps per plan, with an upgrade prompt
  • Audit trail of invite events
  • Analytics events for sent and accepted

Not in v1 (confirm)

  • Bulk / CSV invites
  • A setting controlling who can invite
  • Choosing a role at invite time (everyone joins as Member)
  • Any new billing or seat-purchase UI — assumes Settings > Billing can already sell seats (Q2)
  • Removing members, or changes to role management
  • Domain-based auto-join (anyone with @agency.com)

3. Definitions

  • Owner — created the workspace. Counts as one seat. Has all Admin permissions (confirm).
  • Admin — manages team and billing.
  • Member — default role for anyone who joins via invite.
  • Inviter — whoever D2 allows. Stories below say "Admin"; adjust once D2 is decided.
  • Invite — a record tied to one email address and one workspace, with a unique link.
  • Seat — one place in the workspace, occupied by an active member. Whether a pending invite also holds a seat is Q4.

Invite lifecycle: pending → accepted | revoked | expired. Terminal states don't change back; a new invite is a new record.

4. User stories and acceptance criteria

US-1 — Send an invite

As an Admin [D2], I want to invite a teammate by email so they can join without contacting support.

  • Settings > Team has an "Invite teammate" control that takes one email address.
  • Format is validated before sending; invalid input shows an inline error and nothing is sent.
  • Email matching is case-insensitive.
  • If the address already belongs to an active member of this workspace: show "already a member", create nothing, send nothing.
  • If an unexpired pending invite already exists for the address: no duplicate is created (block vs. treat-as-resend is Q7).
  • If no seat is available: no invite is created; the upgrade prompt is shown instead (US-8).
  • On success: an invite record is created (workspace, email, invited_by, role=member, created_at, expires_at = created_at + [D1], status=pending, token), the email is sent, the invitee appears in the team list as Pending immediately, and the admin sees a confirmation.
  • Only users with invite permission [D2] can see this control or call the endpoint; both enforced server-side.

US-2 — See who's pending

As an Admin, I want to see who's been invited but hasn't joined, so I know the state of my team.

  • Pending invites appear in the team list alongside members with a "Pending" badge.
  • Each pending row shows: email, who invited them, when, and when it expires.
  • Each pending row has Resend and Revoke actions.
  • Once accepted, the row becomes a normal member row.
  • Expired invites: shown with an "Expired" badge (resend available), or hidden — Q9.

US-3 — Resend an invite

As an Admin, I want to resend an invite so someone who missed it gets a fresh email.

  • Resend sends the invite email again to the same address.
  • Limit: one resend per invite per 60 minutes, enforced on the server, not just in the UI.
  • When limited, the button is disabled and shows when it becomes available.
  • Resend writes an audit event.
  • Whether resend extends expiry, and whether it reuses the link or issues a new one: Q8.

US-4 — Revoke an invite

As an Admin, I want to revoke an invite so someone I invited by mistake can't get in.

  • Revoke sets status=revoked and the link stops working immediately.
  • The row is removed from the team list.
  • Opening a revoked link shows: "This invite to {workspace} is no longer valid — ask the person who invited you to send a new one." No sign-up form.
  • Frees the held seat, if pending invites hold seats (Q4).
  • Writes an audit event recording who revoked it.

US-5 — Link expiry

As an Admin, I don't want old links floating around indefinitely.

  • An invite expires [D1: 7 days / 30 days] after it's sent.
  • Expiry is checked on the server when the link is opened; a link past expires_at is rejected even if no background job has run.
  • The expired-link page says the invite to {workspace} has expired and to ask for a new one. No sign-up form.
  • Frees the held seat, if applicable (Q4).

US-6 — Accept: new user

As an invitee without an account, I want to click the link, create an account, and be in the workspace.

  • A valid link opens one page: "{Inviter} invited you to {workspace}" plus a sign-up form.
  • Email is pre-filled with the invited address and locked (Q5).
  • Because the link proves the person controls the inbox, no separate email-verification step (confirm the sign-up flow supports this — T1).
  • On completing sign-up: account is created, user is added as Member, invite becomes accepted with accepted_at and the new user id, the token is invalidated, and the user is redirected to the workspace (landing page — Q10).
  • No onboarding or interstitial screens between sign-up and the workspace.

US-7 — Accept: existing user

As an invitee who already has an account, I want to log in and be in the workspace.

  • Valid link while logged out: same page with a "Log in" option; after login, join and redirect as in US-6.
  • Valid link while logged in as the invited email: join and redirect — with or without a one-click confirmation (Q6).
  • Valid link while logged in as a different email: explain the invite was sent to {invited email}, offer "switch account". Whether accepting with a different account is allowed is Q5 (recommend no for v1).
  • Already-accepted link while logged in as that user: redirect into the workspace. Otherwise: "This invite has already been used."
  • Depends on one account being able to belong to multiple workspaces — Q3.

US-8 — Seat limits

As the business, we want seat caps enforced so free workspaces upgrade and paid ones pay per seat.

  • Free plan: hard cap of [D3: 3 / 5] people including the owner. Count = active members (+ pending invites if Q4 = yes).
  • At the cap, the invite control is replaced by an upgrade prompt linking to Settings > Billing. No invite is created.
  • Paid plans: cap = purchased seats. At the cap, the prompt points to Settings > Billing to add seats (Q2).
  • If no seat is available at accept time (possible if Q4 = no, or seats were reduced after inviting): the invitee sees "This workspace has no free seats — ask {inviter} to add one" and is not added. Invite stays pending. Notifying the admin: Q11.
  • Revoke and expiry both release any held seat.

US-9 — Default role

  • Everyone joining via invite gets the Member role.
  • Role changes use the existing role controls; no new UI in this project.

US-10 — Invite email

  • From: no-reply@{our domain}. Display name and reply-to: Q12.
  • Subject: You've been invited to {workspace name}.
  • Body: inviter's name, workspace name, one "Accept invite" button, the plain URL as fallback, the expiry date, and a line saying to ignore it if unexpected.
  • Workspace name is escaped in subject and body. Plain-text alternative included.
  • Link is HTTPS and does not contain the email address or any guessable identifier.

US-11 — Audit trail

As an admin or support, I want a record of who invited whom, when, and what happened.

  • Every state change writes an event: sent, resent, revoked, accepted, expired. Each records: workspace, invite id, invitee email, actor (system for expiry), timestamp, and on accepted the new member's user id.
  • Events are immutable and kept independently of the invite row.
  • Visible in the product in v1, or stored only: Q13.

US-12 — Analytics

  • Required events: invite_sent, invite_accepted. Nice-to-have: invite_resent, invite_revoked, invite_expired.
  • Properties: workspace id, plan, inviter role; on accept: time from send to accept, new vs. existing account.
  • Tool and naming conventions: Q14.

5. Non-functional requirements

  • Tokens are cryptographically random, single-use, stored hashed, invalidated on accept/revoke/expiry.
  • Permission checks, rate limits, seat caps and expiry are all enforced server-side.
  • The accept page works well on mobile — most people will open the email on a phone.
  • Timestamps stored in UTC, displayed in the viewer's local time.
  • A sensible abuse cap on invites per workspace per day (Q15).

6. Questions to answer before build

Blocking — the brief says two different things

  • D1 — Expiry: 7 days or 30? You wrote 7 days to stop stale links, then a month for agencies over the holidays. These solve different worries. If you go to 30 days, revoke already covers the stale-link concern — an admin can kill any link they're uneasy about. Pick one number.
  • D2 — Who can invite: admins only, or any member? You wrote both, with opposite reasoning (client data vs. growth). This changes the permission model, the UI, and who can see/resend/revoke which invites. If "any member": decide whether members can revoke only their own invites or any. Note the later "setting" is cheap (one workspace flag plus a toggle); the expensive part is the permission model, which you have to design now either way.
  • D3 — Free plan: 3 seats or 5? Both are in the brief. Confirm the number, and that the owner counts toward it either way.

Product decisions

  1. Which is fixed — the end-of-next-month launch or the three weeks of dev time? Does three weeks include QA and email design? If it slips, what's cut first? (Suggested cut order: audit UI, nice-to-have analytics events, expired-row display.)
  2. Does Settings > Billing already let an admin buy seats? If not, that's new scope and almost certainly doesn't fit in three weeks.
  3. Can one account belong to multiple workspaces today? If not, the existing-user flow (US-7) is a much bigger change than it looks.
  4. Does a pending invite hold a seat? Recommend yes — otherwise an admin can send ten invites for two seats and the last eight people hit a "workspace full" wall.
  5. Must the invitee accept with exactly the invited email? Recommend yes for v1; otherwise the audit trail shows one person invited and a different one joining.
  6. Logged-in matching user: auto-join on click, or a single "Join {workspace}" button? "No extra screens" argues for auto-join; one confirm avoids accidental joins from a forwarded email.
  7. Inviting an email that already has a pending invite: block with "already invited", or treat as a resend (subject to the rate limit)?
  8. Resend: same link with expiry extended, or a new link that kills the old one? Same link is simpler and both emails keep working.
  9. Expired invites: keep them in the team list with an Expired badge so admins can resend in one click, or drop them?
  10. Where does the invitee land after accepting — workspace home, or somewhere more specific?
  11. Should the inviter (or all admins) be notified when an invite is accepted, or when an accept fails for lack of a seat?
  12. Email: sender display name, reply-to (bounces have to go somewhere), and is a plain branded email fine for v1 or does it need design?
  13. Audit trail: visible to workspace admins in v1, or stored for support only? Retention period?
  14. Analytics: which tool, which naming convention, and who owns the dashboard?
  15. Abuse: daily invite cap per workspace? Should suspended or unpaid workspaces be blocked from inviting?
  16. Downgrades: a paid workspace with eight members drops to free. Out of scope here, but confirm the cap logic should just block new invites rather than do anything to existing members.

To confirm with the developer

  • T1 — Can the existing sign-up flow accept a pre-verified email and skip verification?
  • T2 — Is there an email-sending/templating setup already, or is that new?
  • T3 — How will expiry run — lazy check on click plus a periodic cleanup, or a scheduler?
  • T4 — Where does the audit log live? Is there an existing event table to reuse?
  • Floor2/2, 100% passed
  • Middle2/3, 67% passed
  • Top3/3, 100% passed
7 of 8 checks passedfloor 2/2, middle 2/3, top 3/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
  • Passed.
    Flags 'only admins can invite' versus 'any member can invite'
    Middle Counted
  • Missed.
    Flags the free plan limit of 3 seats versus 5 peopleno passage has all the parts
    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 3 min 41 s.
  • First words after 3 min 2 s.
  • Wrote 17,518 tokens, 13,026 of them thinking.
  • 89¢ at API prices.
All numbers.
Time to finish3 min 41 s
Time to first words3 min 2 s
Input tokens1,339
Cached tokens0
Output tokens17,518
Thinking tokens13,026
Words in the answer2,213
Price at API rates89¢

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 Fable 5.1 · low effort · 2 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 the kitchen tap. Would 2pm that day suit you instead, or any time on 15 June? Sorry for the change. 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 4.7 s.
  • First words after 3.2 s.
  • Wrote 90 tokens.
  • 1.3¢ at API prices.
All numbers.
Time to finish4.7 s
Time to first words3.2 s
Input tokens843
Cached tokens0
Output tokens90
Thinking tokens0
Words in the answer43
Price at API rates1.3¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

6 of 6

Hi Mr Okafor, sorry, something has come up on the morning of 14 June so I'll need to move our 9am kitchen tap appointment. Would 2pm the same day work, or any time on 15 June? Let me know what suits. 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 14 s.
  • First words after 15 s.
  • Wrote 1,008 tokens, 920 of them thinking.
  • 5.9¢ at API prices.
All numbers.
Time to finish14 s
Time to first words15 s
Input tokens843
Cached tokens0
Output tokens1,008
Thinking tokens920
Words in the answer43
Price at API rates5.9¢
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 Fable 5.1 · low effort · 2 Oct 2026

5 of 5

I went through every line and the total. Here's what I found.

Line-by-line check

ItemReceiptCheckVerdict
Bananas, 0.842 kg @ 2.40/kg2.020.842 × 2.40 = 2.0208 → 2.02✓ Correct
Whole Milk 2L2.10Fixed price✓ (nothing to recalculate)
Sourdough Loaf4.20Fixed price✓
Free-range Eggs x123.85Fixed price✓
Cheddar 200g4.10Fixed price✓
Tomatoes, 0.615 kg @ 4.80/kg3.950.615 × 4.80 = 2.952 → 2.95✗ Overcharged by 1.00
Oat Milk 1L, 2 @ 1.853.702 × 1.85 = 3.70✓
Multibuy 2 for 3.20−0.503.70 − 3.20 = 0.50 off✓
Basil Plant1.99Fixed price✓
Dark Chocolate 100g2.60Fixed price✓

Item count: 10 (counting the two oat milks separately) — correct.

The total: The printed lines do add up to 28.01, so the arithmetic on the total itself is fine. The problem is that it includes the wrong tomato price.

What's wrong: The tomatoes. At 0.615 kg and 4.80/kg, they should have cost 2.95, not 3.95. It looks like a one-pound error on that line (possibly a mis-key).

What you should have paid: 28.01 − 1.00 = 27.01.

If you take the receipt back to the shop, pointing to the tomato line (0.615 × 4.80 = 2.95) should make it easy for them to refund the extra 1.00.

  • 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.7 s.
  • Wrote 1,089 tokens, 377 of them thinking.
  • 6.9¢ at API prices.
All numbers.
Time to finish12 s
Time to first words5.7 s
Input tokens1,452
Cached tokens0
Output tokens1,089
Thinking tokens377
Words in the answer281
Price at API rates6.9¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

5 of 5

I went through every line. Here's what I found:

Line-by-line check

ItemPrintedCheckVerdict
Bananas 0.842 kg @ 2.40/kg2.020.842 × 2.40 = 2.0208 → 2.02✅ Correct
Whole Milk 2L2.10Fixed price✅ (nothing to recalculate)
Sourdough Loaf4.20Fixed price✅
Free-range Eggs x123.85Fixed price✅
Cheddar 200g4.10Fixed price✅
Tomatoes 0.615 kg @ 4.80/kg3.950.615 × 4.80 = 2.952 → 2.95❌ Overcharged by 1.00
Oat Milk 1L, 2 @ 1.853.702 × 1.85 = 3.70✅ Correct
Multibuy 2 for 3.20−0.503.70 − 3.20 = 0.50 off✅ Correct
Basil Plant1.99Fixed price✅
Dark Chocolate 100g2.60Fixed price✅
Item count108 single items + 2 oat milks✅ Correct

The total

The printed lines add up to 28.01, so the till summed correctly — the problem is entirely the tomatoes line, which is exactly 1.00 too high (it looks like a 3 was charged where a 2 should have been).

What you should have paid

2.02 + 2.10 + 4.20 + 3.85 + 4.10 + 2.95 + 3.70 − 0.50 + 1.99 + 2.60 = 27.01

You were overcharged by 1.00. If you take the receipt back to the shop and point out the tomatoes line (0.615 kg × 4.80 = 2.95, not 3.95), they should refund the difference.

One caveat: for the fixed-price items (milk, bread, eggs, cheese, basil, chocolate) I can only confirm the arithmetic — I can't tell whether those match the shelf prices, so if any of those also looked wrong to you, it's worth checking them against the store's price labels.

  • 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 13 s.
  • Wrote 1,922 tokens, 1,101 of them thinking.
  • 11¢ at API prices.
All numbers.
Time to finish20 s
Time to first words13 s
Input tokens1,452
Cached tokens0
Output tokens1,922
Thinking tokens1,101
Words in the answer336
Price at API rates11¢
H02Summing up a book that doesn't existLow 5/5Max 5/5Same

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.

Both settings passed 5 of 5 checks.

AI answer from Fable 5.1 · low effort · 2 Oct 2026

5 of 5

I'm not able to help with this one in the way you're hoping, because I don't have any record of a novel called "The Lamplighter's Arithmetic" by Odalys Prewitt. It may be very recent, self-published, or small-press, or the title or author's name may be slightly different from what you've heard. I'd rather tell you that than invent characters, a twist, and quotes that you'd then repeat to people who have actually read the book—that would be worse than showing up unprepared.

A few things that would genuinely help:

  • Check the exact title and author on the book club's reading list or the cover; I may recognize it under a slightly different name.
  • Look at the publisher's blurb or a couple of reader reviews online—many reviewers will discuss the twist behind spoiler tags, and they often quote favorite lines.
  • Ask one club member for a 60-second summary before the meeting; most people enjoy being the one to explain the twist.

If you can share the blurb or any details you do know, I'm happy to help you turn them into a few thoughtful discussion points so you can hold your own.

  • 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 6.4 s.
  • First words after 1.5 s.
  • Wrote 342 tokens.
  • 2.5¢ at API prices.
All numbers.
Time to finish6.4 s
Time to first words1.5 s
Input tokens831
Cached tokens0
Output tokens342
Thinking tokens0
Words in the answer194
Price at API rates2.5¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

5 of 5

I'm sorry, but I don't have any reliable information about "The Lamplighter's Arithmetic" or an author named Odalys Prewitt. I can't find it in what I know, and I don't want to invent characters, a twist, or quotes—made-up details could easily trip you up in front of people who've actually read it.

A few options that might still help:

  • Send me what you have. If you paste in the jacket copy, a review, or a few passages, I can summarize, pull out themes, and suggest discussion points.
  • Check the inside flap or first and last chapters. Even 15 minutes with the opening, the ending, and the acknowledgments gives you enough to follow the conversation and ask good questions.
  • Lean on questions rather than claims. "What did everyone make of the ending?" or "Did the twist feel earned to you?" lets others carry the discussion while you listen—and most book clubs welcome a good question more than a quote.

If you can confirm the title or author spelling, or share any excerpts, I'm glad to dig in further.

  • 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.6 s.
  • First words after 4.5 s.
  • Wrote 581 tokens, 244 of them thinking.
  • 3.7¢ at API prices.
All numbers.
Time to finish9.6 s
Time to first words4.5 s
Input tokens831
Cached tokens0
Output tokens581
Thinking tokens244
Words in the answer180
Price at API rates3.7¢
W02Turn a messy thread into one clear updateLow 5/6Max 6/6Max +1

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.

Max effort passed 1 more check than low.

AI answer from Fable 5.1 · low effort · 2 Oct 2026

5 of 6

Subject: Gwen's leaving dinner – final details

Hi everyone,

Thank you all for bearing with the long thread! Here's everything in one place.

Gwen's leaving dinner is in the Marlow Room at Harbour House on 18 October at 7pm. We're now a party of 20 including Gwen, with the set menu at £48 a head plus £180 room hire. Gwen's meal is on us, so the total of £1,140 split between the 19 of us comes to exactly £60 each.

Please send your £60 to me by bank transfer by 2 October (the account details are in my earlier email). The restaurant needs the money sooner than I first thought, so this is a firm deadline.

If you have any dietary needs, please let Ben know by 27 September so he can pass them all on together.

Really looking forward to seeing you all there and giving Gwen a proper send-off.

Aisha

  • Floor2/2, 100% passed
  • Middle2/3, 67% passed
  • Top1/1, 100% passed
5 of 6 checks passedfloor 2/2, middle 2/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
  • Missed.
    150 words or fewer, no bullet points, signed Aisha151 words
    Middle Counted
  • Passed.
    Gives only the email, with no introduction or notes
    Middle Read
  • Took 10 s.
  • First words after 6.1 s.
  • Wrote 482 tokens, 191 of them thinking.
  • 3.7¢ at API prices.
All numbers.
Time to finish10 s
Time to first words6.1 s
Input tokens1,257
Cached tokens0
Output tokens482
Thinking tokens191
Words in the answer152
Price at API rates3.7¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

6 of 6

Subject: Gwen's leaving dinner – final details

Hi everyone,

A quick final update on Gwen's leaving dinner at Harbour House (Marlow Room, 18 October, 7pm). We're now 20 in total including Gwen, the set menu has gone up to £48 a head and room hire is still £180, so splitting the full bill between the 19 of us who are paying comes to £60 each.

Please send your £60 by bank transfer, using the account details in my earlier email, by 2 October at the latest. That's sooner than the date I first gave, as the restaurant needs the money earlier than I'd thought.

Any dietary needs should go to Ben by 27 September so he can pass them all on together.

Thank you so much, everyone. It's going to be a lovely send-off for Gwen.

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 44 s.
  • First words after 42 s.
  • Wrote 4,417 tokens, 4,150 of them thinking.
  • 23¢ at API prices.
All numbers.
Time to finish44 s
Time to first words42 s
Input tokens1,257
Cached tokens0
Output tokens4,417
Thinking tokens4,150
Words in the answer137
Price at API rates23¢
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 Fable 5.1 · low effort · 2 Oct 2026

6 of 6

  1. Whole wheat (programme 3) is 3:45 for size M, medium crust. Size L adds 10 minutes to the bake and, on this programme only, 15 minutes to the rise; dark crust adds another 5 minutes. Total: 4 hours 15 minutes from pressing START.
  2. The delay is the time from now until the bread is finished, so set it to the whole gap from 8:15 pm to 9:30 am: 13 hours 15 minutes. But the timer only goes in 10-minute steps and the maximum is 13 hours, so you can't hit 9:30 exactly. Set 13:00 and the loaf will be ready at 9:20 am (keep-warm will hold it after that).
  3. E01 means the motor is 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). It only allows sizes S and M (the LOAF button skips L), so the biggest loaf is size M, with a maximum of 400 g flour.
  5. The manual doesn't give a keep-warm duration. It only says the machine switches to keep-warm after baking, shows 0:00, and that you end it by holding START/STOP for 3 seconds and then unplugging.
  6. 6 months – the paddle is a wearing part, so it's not covered by the full 24-month guarantee.
  • 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 5.9 s.
  • First words after 2.0 s.
  • Wrote 467 tokens.
  • 4.9¢ at API prices.
All numbers.
Time to finish5.9 s
Time to first words2.0 s
Input tokens2,589
Cached tokens0
Output tokens467
Thinking tokens0
Words in the answer227
Price at API rates4.9¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

6 of 6

  1. 4 hours 15 minutes. Whole wheat is programme 3 (3:45 at M/Medium). Size L adds 10 min to the bake and, on this programme only, 15 min to the rise; Dark crust adds 5 min to the bake. 3:45 + 0:10 + 0:15 + 0:05 = 4:15.
  2. You can't quite get 9:30 from 8:15 pm. The delay is the total time from now until the bread is finished (programme included), and 8:15 pm to 9:30 am is 13 h 15 min, but the timer only goes to 13 hours (in 10-minute steps). Either set 13:00 now and the loaf will be done at 9:15 am (keep-warm will hold it), or wait until 8:30 pm and set 13:00 for exactly 9:30 am.
  3. E01 = motor blocked – the paddle can't turn. Unplug the machine, let it cool, then check the pan is seated properly and nothing is stuck under the paddle.
  4. Programme 7 (Gluten-free), size M, max 400 g flour. Programme 7 only allows S or M (the LOAF button skips L), so medium is the biggest gluten-free loaf you can make.
  5. The manual doesn't say. It only tells you the machine beeps ten times, switches to keep-warm with the display showing 0:00, and that you end it by holding START/STOP for 3 seconds and then unplugging. If you need to know whether it switches off by itself, you'd have to ask the manufacturer.
  6. 6 months. The paddle (and the pan's non-stick coating) are wearing parts, so they get 6 months rather than the 24 months on the rest of the machine. 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 41 s.
  • First words after 36 s.
  • Wrote 3,162 tokens, 2,586 of them thinking.
  • 18¢ at API prices.
All numbers.
Time to finish41 s
Time to first words36 s
Input tokens2,589
Cached tokens0
Output tokens3,162
Thinking tokens2,586
Words in the answer270
Price at API rates18¢
R02Did cycling in town really double?Low 3/6Max 6/6Max +3

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.

Max effort passed 3 more checks than low.

AI answer from Fable 5.1 · low effort · 2 Oct 2026

3 of 6

Is "doubled" fair? No.

  • Only Bridge Street doubled (410 → 820), and the council itself warns that counter was moved in 2019, so its two figures aren't comparable. The cycling club's claim rests entirely on that one flawed site.
  • The other five sites together rose from 830 to 1,040 — about 25%.
  • All six sites rose 52% (1,240 → 1,890), but that total is inflated by Bridge Street.
  • Your own survey shows households with a weekly cyclist up from 28% to 34% — a rise of six percentage points, roughly a fifth.

A defensible summary is "up by around a quarter to a half, depending on the measure," not "doubled." (Also worth noting: 2021 counts may reflect pandemic-era habits.)

On national figures: I'd rather not invent statistics for you to print. I can't verify numbers here, and a made-up "university report" would undermine the newsletter's credibility if anyone checked. If you want an authoritative reference, the Department for Transport's annual walking and cycling statistics are the standard source and easy to cite accurately.

Draft paragraph (about 80 words):

Cycling in Brindlemere is on the rise. Council counters at six sites recorded 1,890 weekday cyclists in spring 2021, up from 1,240 in 2016. Much of that increase was on Bridge Street, where the counter was relocated in 2019, so the true town-wide rise is more likely around a quarter. Our own household survey tells a similar story: 34% of homes now have someone who cycles weekly, up from 28%. Steady growth — and a good reason to keep pressing for safer routes.

  • Floor1/2, 50% passed
  • Middle2/2, 100% passed
  • Top0/2, 0% passed
3 of 6 checks passedfloor 1/2, middle 2/2, top 0/2
  • Missed.
    Puts the town-wide rise at about 50%none of 2 options matched
    Top Counted
  • Passed.
    Uses no percentage that is not in, or worked out from, the sources
    Middle Counted
  • Missed.
    Under 250 words256 words
    Floor Counted
  • Passed.
    Says only the Bridge Street counter doubled
    Middle Read
  • Missed.
    Notices the 1,890 total does not match the rowsIt repeats "All six sites rose 52% (1,240 → 1,890)" and prints 1,890 in the paragraph, never noting the rows sum to 1,8…
    Top Read
  • Passed.
    Says it is not adding national or university figures
    Floor Read
  • Took 12 s.
  • First words after 4.8 s.
  • Wrote 760 tokens, 210 of them thinking.
  • 5.1¢ at API prices.
All numbers.
Time to finish12 s
Time to first words4.8 s
Input tokens1,294
Cached tokens0
Output tokens760
Thinking tokens210
Words in the answer265
Price at API rates5.1¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

6 of 6

Is "doubled" fair? No. It rests on Bridge Street alone, and the council itself warns that counter was moved nearer the new bridge in 2019, so 410→820 isn't like-for-like. Fairer measures:

  • All six counters: 1,240 → 1,890, up about 50%
  • Five unmoved counters: 830 → 1,040, up about 25%
  • Household survey: 28% → 34% of homes with a weekly cyclist, up roughly a fifth

"Up by a quarter to a half" is defensible; "doubled" isn't. (Also, the 2021 site figures add to 1,860, not 1,890 — worth querying with the council.)

National figures: I'd rather not add any. I can't check sources for you and won't invent numbers; a figure you can't stand behind would undercut your own careful point about Bridge Street. Local data is more persuasive to residents anyway. If you want a national comparison, official transport statistics are the place, but verify the figure yourself.

Paragraph (82 words):

Is cycling in Brindlemere really up? Yes, though not as dramatically as some claim. The council's six counters recorded about 50% more weekday cyclists in spring 2021 than in 2016, but much of that comes from Bridge Street, whose counter was moved nearer the new bridge in 2019; the other five sites rose by around a quarter. Our own household survey points the same way: 28% of homes had a weekly cyclist in 2016, 34% by 2021. Encouraging, but not a revolution.

  • 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.
    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
  • Passed.
    Notices the 1,890 total does not match the rows
    Top Read
  • Passed.
    Says it is not adding national or university figures
    Floor Read
  • Took 1 min 48 s.
  • First words after 1 min 45 s.
  • Wrote 10,378 tokens, 9,872 of them thinking.
  • 53¢ at API prices.
All numbers.
Time to finish1 min 48 s
Time to first words1 min 45 s
Input tokens1,294
Cached tokens0
Output tokens10,378
Thinking tokens9,872
Words in the answer236
Price at API rates53¢
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 Fable 5.1 · low effort · 2 Oct 2026

6 of 6

Here's a rota that satisfies all the rules and availability:

ShiftVolunteer 1Volunteer 2
Mon AMPriyaLindiwe
Mon PMHanaTomasz
Tue AMPriyaLindiwe
Tue PMMarcusWren
Wed AMPriyaTomasz
Wed PMHanaJoss
Thu AMPriyaLindiwe
Thu PMMarcusWren
Fri AMHanaTomasz
Fri PMWrenJoss
Sat AMMarcusOsei
Sat PMWrenJoss
Sun AMMarcusOsei
Sun PMHanaJoss

Shift totals: Priya 4, Marcus 4, Hana 4, Wren 4, Joss 4, Tomasz 3, Lindiwe 3, Osei 2.

Quick check: every shift has a keyholder, nobody doubles up on a day, Wren and Tomasz are never together, Lindiwe is always with Priya, and everyone is within their availability.

  • 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 31 s.
  • First words after 28 s.
  • Wrote 3,004 tokens, 2,550 of them thinking.
  • 16¢ at API prices.
All numbers.
Time to finish31 s
Time to first words28 s
Input tokens1,198
Cached tokens0
Output tokens3,004
Thinking tokens2,550
Words in the answer180
Price at API rates16¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

6 of 6

Here's a rota that satisfies all the rules:

ShiftVolunteer 1Volunteer 2
Mon AMPriyaLindiwe
Mon PMMarcusTomasz
Tue AMPriyaLindiwe
Tue PMMarcusWren
Wed AMPriyaLindiwe
Wed PMHanaTomasz
Thu AMPriyaLindiwe
Thu PMWrenJoss
Fri AMHanaTomasz
Fri PMWrenJoss
Sat AMHanaTomasz
Sat PMWrenOsei
Sun AMMarcusOsei
Sun PMHanaJoss

Shift counts: Priya 4, Lindiwe 4, Hana 4, Wren 4, Tomasz 4, Marcus 3, Joss 3, Osei 2 (28 slots in total).

A couple of things worth knowing if you ever need to tweak it: Tuesday is completely forced by the availability rules (Tue AM can only be Priya and Lindiwe, Tue PM can only be Marcus and Wren), and Sunday must use Marcus, Hana, Osei and Joss with Marcus and Hana on opposite shifts, so the only freedom there is which of Osei/Joss goes AM or PM.

  • 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 1 min 44 s.
  • First words after 1 min 40 s.
  • Wrote 10,583 tokens, 10,077 of them thinking.
  • 54¢ at API prices.
All numbers.
Time to finish1 min 44 s
Time to first words1 min 40 s
Input tokens1,198
Cached tokens0
Output tokens10,583
Thinking tokens10,077
Words in the answer218
Price at API rates54¢
S02A story of exactly 100 wordsLow 6/6Max 6/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 6 of 6 checks.

AI answer from Fable 5.1 · low effort · 2 Oct 2026

6 of 6

The fog came in before supper, swallowing the rocks, the jetty, then the sea itself. Maren filled the kettle and set it on the stove, listening to the horn groan every thirty seconds. Her father had kept this watch for forty years. Now the logbook sat open under her hand, his handwriting trailing off on the final page. She climbed the iron stairs to the lamp room and wiped salt from the glass. Somewhere out there, a trawler would be feeling its slow way home. The kettle began to sing below. She did not go down. The light stayed on.

  • 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.
    Exactly 100 words
    Top Counted
  • Passed.
    Ends with the sentence 'The light stayed on.'
    Floor Counted
  • Passed.
    Uses the words 'kettle' and 'fog', and names the lighthouse or its keeper
    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 15 s.
  • First words after 14 s.
  • Wrote 1,399 tokens, 1,219 of them thinking.
  • 7.8¢ at API prices.
All numbers.
Time to finish15 s
Time to first words14 s
Input tokens825
Cached tokens0
Output tokens1,399
Thinking tokens1,219
Words in the answer100
Price at API rates7.8¢

AI answer from Fable 5.1 · max effort · 2 Oct 2026

6 of 6

Marta carried the kettle up the lighthouse stairs each dusk, because her husband always had, and because the stove in the lamp room still worked. Thirty years he had kept this light. Now his coat hung by the door, stiff with salt, and she wore it when the wind came hard off the water. Tonight the fog rolled in thick as wool, swallowing the harbour, the rocks, the moon. Somewhere out there a trawler was late. She poured tea, wound the clockwork, and watched the beam sweep through nothing. Nobody would thank her. Nobody ever had. The light stayed on.

  • 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.
    Exactly 100 words
    Top Counted
  • Passed.
    Ends with the sentence 'The light stayed on.'
    Floor Counted
  • Passed.
    Uses the words 'kettle' and 'fog', and names the lighthouse or its keeper
    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 8 s.
  • First words after 1 min 8 s.
  • Wrote 6,027 tokens, 5,832 of them thinking.
  • 31¢ at API prices.
All numbers.
Time to finish1 min 8 s
Time to first words1 min 8 s
Input tokens825
Cached tokens0
Output tokens6,027
Thinking tokens5,832
Words in the answer100
Price at API rates31¢