Clip AI streams its replies, cards you can click, and email when money moves
Added
Briefings have a floor: lib/ai/fallback-brief.ts composes one from the board itself.
When the model returns thinking, nothing, or a truncated reply, the briefing is now built in
code from cards, connections, strokes and open comment threads — what the space holds, the
items the user named, whether anything is connected, and one concrete next step. Plain, but
never wrong and never empty. The chat says why it reads plainer than usual; the Notion export
doesn't, because an exported document should read as a document rather than as an apology.
includeAi on a Notion export no longer produces an empty section. A user who asked for
a summary and has no model configured gets the composed one instead of nothing.
It is step one of a larger direction, not a patch beside it — code authors the document
and the model fills it in, rather than the model authoring and eight scrubbers policing.
Written up in Notion as "11. AI output contract — future overhaul".
KIND_NOUNS is a Record over the card-kind union, so adding a card kind fails the build
here rather than quietly describing the new thing as "card". A floor with a hole in it is
not a floor.
Cards the assistant mentions are now links you can click to jump to them. Ask about a
photo and the reply used to hand back the plumbing: The image card [377f9470-3d0d-4164-9a80-e8e92339fe51] is a close-up of water… positioned at (534, 251) and measures 900 × 600 px. All three of those are addressing information the model needs and
the reader does not — a uuid, canvas coordinates, pixel dimensions — and nothing in the
context format said so. Now it writes "the water photo", and that phrase is a link: click
it and the canvas scrolls and zooms to the card.
The prompt asks; lib/ai/card-refs.ts assumes it will be ignored. It was — twice, by
the model this app ships as its base tier. So ids are rewritten into the link the model
should have written (the failure degrades into the feature), and sentences that report a
card's real position or pixel size are dropped whole. Deleting just the numbers would
leave "It sits at coordinates on the canvas and measures px", which is worse than the
leak; the sentence goes, or nothing does.
A geometry sentence must fail two independent checks to be dropped: it has to be
phrased as a position/size claim AND quote a value belonging to a card actually on this
board. Either test alone over-matches — a note reading "the poster measures 1200 × 800"
trips the first, a sticky that happens to say "(534, 251)" trips the second. And it is
skipped entirely when the user's own question asked where or how big something is, since
then the geometry is the answer rather than an artefact.
Ids are matched with any dash the model felt like using. A model that writes prose
also punctuates it — curly quotes, × between dimensions — and the same instinct rewrites
the hyphens inside a uuid it is quoting, usually to U+2011 so it won't wrap. The first
version of this scrub matched ASCII - only and let the very next leak straight through.
An id naming nothing on the board is deleted, not linked. A link that focuses nothing
is worse than no link — the user clicks, nothing moves, and they stop trusting the ones
that work. The gap it leaves is closed too, down to the orphaned (see ).
Code survives intact. Fenced blocks and inline code pass through both passes
untouched: a uuid inside a fence is being shown deliberately, and the whitespace in there
is the content. Nested list indentation survives for the same reason.
Clicking a link for a card that has since been deleted says so, the way the notification
path already does. Replies written before this change still show their raw ids — history
is not rewritten.
Paying now sends an email. Money changed hands and nothing acknowledged it — no
confirmation, no receipt, no record of what was charged. There are two new emails, and the
distinction between them is the whole feature: subscriptionStartedEmail on the first
paid charge or a change of tier, paymentReceiptEmail on every charge after that. Both
charge.success (Paystack) and transaction.completed (Paddle) fire on renewals too — that
is deliberate, it is what makes the grant self-healing — so without the split, a subscriber
would be congratulated on subscribing every month for a year.
Sent from the grant, not from a route. A successful charge reaches us down four paths
(two webhooks, two /api/billing/verify branches) that already converge on
grantFromCharge / grantFromPaddleTransaction for the reason lib/billing/grant.ts
explains at length. The email joins them there, in lib/billing/notify.ts, so no route
decides for itself whether to send.
At most one email per charge. The webhook and /verify routinely process the same
charge seconds apart. Idempotent upserts make that harmless; an email is not idempotent.
The send is claimed first in billing_events — the same unique key the webhook already
uses to drop duplicate deliveries — and a losing insert stays quiet. A claim whose send
failed is released so a redelivery can retry.
What was actually charged, not the catalog price.formatMinor() renders the amount
the processor captured, so the receipt matches the bank statement rather than the pricing
page — GH₵75.00, not $6. Two decimals always, for the same reason.
A mobile-money term is never described as renewing. It says "Access until" where a card
subscription says "Renews on", and the fine print says plainly that nothing will renew on
its own. readPriorState() runs before the grant's upsert, because subscriptions is
keyed on user_id and the write is what makes "were they already paying?" unanswerable.
detailRows() in the email shell — a hairline-separated label/value block. A receipt is
read by someone checking a number against a statement, and prose is the wrong shape for it.
Replies now arrive word by word instead of appearing whole. A finished answer used to be
painted in a single frame — several paragraphs materialising at once, with nothing to tell you
where to start reading. Words now resolve out of blur at reading pace.
It is a simulation, and worth being plain about: /api/ai is not a streaming route, so the
reply is complete before a single word of it is on screen. That turns out to be the better
trade here. The reveal is pure CSS — every word is a span with a staggered animation-delay,
so there is no timer, no re-render per word, and no half-written Markdown ever reaches the
parser. A real stream renders **bold and unclosed fences as literal syntax that then rewrites
itself, and that flicker is worse than no animation. It also means the reply's layout is final
on the first frame: nothing reflows as it reveals, the thread doesn't jump, copy works
immediately, and a screen reader gets the whole answer at once rather than one word at a time.
The pace adapts — a long reply reveals faster per word so the whole thing lands in about the
same time rather than taking twenty seconds because it is long. Code blocks reveal whole
(whitespace is content there). Only the reply that just landed animates, once: opening a chat
does not cascade its history, and switching between the compact popover and the agent view
mid-reveal doesn't replay it. Off entirely under prefers-reduced-motion.
You can queue a follow-up while Clip AI is still working. The composer used to be locked out
for the length of a run: the textarea greyed, and Enter on anything you typed was a silent no-op.
The thought you had while reading the answer is the one you most want to get down, so the box
now stays live — what you type goes into a queue, and each turn sends the moment the run before
it lands. Queued turns sit at the foot of the thread in the slot they will fill, dashed rather
than solid so nobody mistakes one for sent, and each can be pulled back out before it goes.
Switching chats abandons the queue with it; those turns were meant for the thread you left.
A Stop button, in both chat modes. While a run is in flight the send button becomes Stop, and
it aborts the request. With a draft in the box a second ↑ appears beside it to queue that draft —
a mouse-only user with something typed otherwise has no way to say "after this one", and Stop is
emphatically not it. Stopping clears the queue too: a Stop that leaves three more turns to fire
is not a stop. The question already in flight stays on the server (it's written before the model
is called), so the note reads "Stopped" rather than claiming the turn was undone.
Changed
Agent mode and the compact chat are now one panel that changes size. Switching between them
was a cut: a 400px card in the bottom-right corner faded out while a full-screen workspace faded
in over it, two unrelated things that happened to occupy the same moment. They're now the same
surface — the box travels between the corner and the viewport, corners rounding out and back, and
the two conversations cross-fade across it. Going back, the workspace shrinks into the corner it
came from, so the panel keeps its place instead of teleporting.
Only the surface makes that trip. Scaling a box scales everything in it, and a panel that is
mostly text can't be stretched between a 400px card and the full viewport without looking cheap —
so neither conversation is ever on screen while the box is moving. The outgoing one clears before
the box has gone anywhere, the box travels empty, and the incoming one fades in at rest at its own
size. Opening the panel is untouched: there's no box in flight there, so it still pops up from the
Ask AI button rather than waiting on anything.
The popover's own resizes animate too, and deliberately not the same way: expanding it, or
sliding the chat list open, keeps the same conversation on screen, so those transition as real
layout and the text reflows into the new width rather than being stretched into it. The chat list
widens open and narrows shut with its rows pinned to the open width, so only the reveal moves.
Under prefers-reduced-motion the two views simply swap.
One loader for a run, in both the compact popover and the agent view. They had drifted into
two: a pulsing dot with a label in one, the same dot with a hand-rolled elapsed clock in the
other. Both now render a 3×3 pixel grid with a wavefront driving across it, a shimmering label,
and a live clock in tabular figures — and the clock reads from when the run began, so switching
between the two views mid-run no longer restarts it at zero. Scanning a space gets an orbiting
variant and answering gets the driving one, so the grid alone says which kind of run you're
waiting on. Reduced motion freezes the grid; the clock still ticks, which is what actually tells
you the run is alive.
The model's reasoning reads as steps instead of a wall. "Thoughts" was a flat scrolling box
of prose. It's now an expandable trace — each line of reasoning a row hung off a vertical rail,
arriving one after another — headed "Thought for 4s" so the disclosure carries the cost of the
turn as well as its contents. Still collapsed by default.
Cards an agent places now arrive as a group, one after another. A batch used to land as a
ragged vertical trickle: each new card was pushed down 56px at a time until it stopped
overlapping something, so the spacing recorded the order the collision checks happened to
resolve in rather than anything about the work. Cards that arrive together are almost always
about one thing, so they now land looking like one thing — packed into a square-ish grid, up
to four columns wide, near whatever the placer already chose (the viewport in the app, below the
existing content on a server). A create that named its own coordinates is untouched: that is how
a flowchart is built, and re-arranging one would destroy the layout the caller was careful to
specify.
They also fade in one after the other rather than all at once, in reading order across the
grid. Only cards a machine placed animate, and only ones stamped in the last few seconds — a
colleague pasting cards, or a reconnect replaying the board, must not set the whole space
cascading. Past a dozen at once nothing animates at all, because that is a bulk load rather than
somebody working. The whole thing is off under prefers-reduced-motion.
Fixed
A briefing showed a user the model's raw thinking instead of a briefing. Asking
"Brief me on this space" returned Here's a thinking process: 1. **Analyze User Input:**…,
stopped mid-sentence at the token cap, and appeared in the thread twice. Four separate
defects, only the first of which was the model's doing:
Brief mode never split thinking out of the answer. Chat mode has called
splitReasoning for months; the brief branch was written without it and passed whatever
came back straight to the bubble. It now runs the same split, keeps the thoughts in the
dropdown where chat mode puts them, and honours truncated — a cut-off briefing is a
sentence that stops, not a shorter briefing, and is no longer shown as if it were finished.
The leak detector could not have caught it.PLANNING_SIGNALS scored zero hits on
the reply: it looks for board-editing planning voice (we'll, let's, x = 120), and this
was first-person narration of the instructions. Detection is now structural first —
echoesPrompt() fires when a reply quotes our own scaffolding (⟦external⟧, the
SPACE CONTENTS header, the safety preamble). Those strings are escaped out of every piece
of content we embed, so their presence in a reply proves it came from the prompt. That arm
is model- and language-agnostic and survives a model swap; the phrase list stays as a
backstop rather than the front line.
Every briefing in a shared chat was duplicated. The brief branch returned no
answerId, so the client appended its own copy and the Realtime INSERT for the stored row
arrived as a second one. It now upserts under the server's row id, exactly as chat mode has
done since rooms shipped.
The same unguarded path wrote to two other surfaces. The Notion export ran the identical
BRIEF_TASK with no reasoning handling — a leak there lands on a page the customer keeps —
and it also re-exported the most recent saved briefing without checking it was ever an
answer. lib/ai/digests.ts stored gists that are fed back as context on later turns, so one
recital poisoned every subsequent request rather than one reply. Both are guarded now.
pnpm stops warning on every command. pnpm 11 no longer reads settings from the pnpm field
in package.json, so its onlyBuiltDependencies: ["esbuild"] was dead weight that printed a
deprecation warning ahead of every script's output. The live setting is already
pnpm-workspace.yaml's allowBuilds, and it says the opposite — esbuild's install script does
not run. That's correct: esbuild 0.21 ships its binary as a platform-specific optional
dependency (@esbuild/win32-x64), so there is nothing for a build step to fetch. The stale field
is gone.
The Twitter/X share card runs on the runtime it asked for.app/twitter-image.tsx re-exported
its whole config — runtime included — from the Open Graph card, and Next.js reads those by
static analysis of the route file itself, so it couldn't see them: it warned on every dev start
and build (can't recognize the exported runtime field in "/twitter-image/route") and quietly
used the defaults. The config is now declared in the file; only the rendering component is still
shared, which is the part that was actually worth deduplicating.
A question no longer says "Sending…" for the whole time the answer is being written. The
asker's bubble sat there claiming to still be sending for thirty-odd seconds, directly beneath a
loader saying "Working on it" — two indicators about two different things, worded as though they
were about the same one. The question had in fact been stored in the first fraction of a second:
/api/ai writes it and then calls the model, both inside one request, so the client couldn't
learn it had landed until the entire answer came back. The bubble now stays quiet while its own
run is in flight and lets the loader speak for that window. Narrowed to the one message the run
belongs to — an earlier turn left unconfirmed by a thrown request keeps saying so while a later
turn runs. Stopping a run also settles the question now: the server had already stored it, and
left alone the bubble would have read "Sending…" forever with nothing coming back to clear it.
A space too big for its model now loses detail, not structure. The board context has always
had a ladder to degrade by — full enrichment, then per-card gists, then bare labels, then an
explicit "and 34 more" tail — but nothing ever called it. The prompt was built at full detail and
then cut to length from the end, and what sits at the end is the CONNECTIONS block. So the first
thing an oversized board gave up was the structure the user drew by hand, while keeping the
scraped body text of whichever cards happened to be first. The budget is now worked out before
the board is rendered, so it degrades in the intended order and every card stays addressable by
id far longer. Comment threads and the layout table are charged against the same budget instead
of being appended after it was already spent.
The assistant knows the pen layer exists on boards that also have comments. There were two
paths building the board context and only one of them passed the drawings through, so on any
space with a comment thread the model was never told strokes were there — it would leave them out
of a summary, and answer "what did I sketch?" as though the canvas were bare instead of saying it
can't read freehand marks. Both paths are now one path.
An agent's edit no longer rewrites the entire board.applyOpsToSpace upserted every card in
the space on every call. Supabase Realtime is subscribed per row, so a three-card change on a
forty-card board fired forty-three change events into every open tab, each one rebuilding that
tab's whole node array to communicate three cards. It now writes only the rows that actually
differ — compared as serialized rows rather than trusted from the operation list, so an op that
resolved to a no-op (a set_task_done whose title matched nothing, a patch setting a field to
what it already was) writes nothing at all. Edges and pen strokes are diffed the same way.
Added
The assistant can tick a task off a to-do list — and can no longer pretend it did.
Asked to mark a task done, it used to say "Marked the PR task as done" and change nothing:
the board vocabulary had no way to reach a task's checkbox, the operation it emitted was
filtered to nothing on the way in, and the prose claiming success was written in the same
breath as the request, so nothing ever contradicted it. Two people reading the same board
then disagree about what is finished, which is the one thing a shared to-do list exists to
prevent.
It is a real ability now, set_task_done, and deliberately unlike the rule next door that
forbids the model from answering a poll. A vote records what a person thinks, so a model
casting one is forgery however confident it is. A checkbox records whether work happened —
a fact about the world, and an assistant that just did the work is often the best-placed thing
in the room to report it. What it must not do is guess, so the prompt draws the line there:
tick what you know is finished, never what you assume is.
A task is addressed by its title, not an id, for the same reason connections are addressed
by their two endpoints — task ids are uuids that never appear in what the model reads, so an
id-addressed verb would be unreachable without spending prompt budget on every task on every
board for something rarely needed. Matching ignores case and spacing but is otherwise exact:
no prefix or fuzzy matching, because "mark the deploy one done" against three tasks starting
"Deploy" has to do nothing rather than pick. A wrongly ticked task is invisible in a way an
unticked one is not — nobody re-reads the boxes already crossed off.
Only done moves. Not the due date, not the assignees, not the checklist: work gets finished
late, and by people other than whoever it was assigned to, and quietly tidying that away would
destroy the record of what actually happened. Everything else about a task is still edited by
people in the card, and the list's own name, colour and width remain the only things
update_card can reach.
Fixed
The assistant no longer reports board changes it didn't make. Two separate holes fed the
false "done" above. The prompt now states that prose may describe only what is in the actions
block it just emitted — no claiming an edit without an action for it, and no claiming an
ability the vocabulary doesn't grant; if it can't do the thing, it has to say so. And the
summary an external agent gets back over MCP now counts ticked tasks from the board rather
than from the operations, unlike every other line in it. That distinction matters because
set_task_done names its task by title: the operation can be perfectly valid and still match
nothing, so counting operations would report a tick over an unchanged checkbox — exactly the
lie the verb was added to stop. A title that matches nothing now comes back as
"Nothing changed."
Clip AI's replies render as Markdown instead of printing the syntax. The model writes
**bold**, headings, bullets, tables and fenced code the way every assistant does; the panel
printed the characters. Replies now render properly — links open in a new tab, so a click never
takes the board with it — in both the chat popover and the full-screen agent view, and in a reply
that died mid-run. Single newlines still break: Markdown folds them into the paragraph, but an
answer written one line per point means them. Code blocks and tables scroll inside themselves, so
a wide reply never widens the panel on a phone.
External agents can now make to-do lists and polls too. The in-app assistant learned both when
they shipped, but the edit_board tool description that MCP clients read had not — and an external
agent knows only the vocabulary that description spells out, so a capability the validator would
have happily accepted was unreachable from Claude Desktop. It also now says outright that a
running focus timer cannot be deleted, so an agent reports the refusal instead of retrying an op
that is being ignored on purpose. A test asserts the description lists every create the server can
actually perform, so it cannot drift again.
Changed
The landing page stops imitating the app and starts introducing it. The header was built as
a lookalike of the in-app top bar, on the theory that a visitor should meet the interface before
they click Open. What that produced was a ✦ Welcome pill — a decorative aria-hidden span
miming the space switcher, pointing at no route, naming a space that does not exist. It looked
like a control and was not one. It is gone, and so is the reasoning that put it there.
The bar is now two groups: identity at the left, one cluster at the right, nothing in the
middle. It used to be four — logo, wordmark, pill, links — each 14px from the next, so the brand
and the navigation ran together into a single line of grey. And it now has one filled element.
The CTA is the only thing in the bar painted with a background; the theme toggle keeps its
bordered look everywhere else it appears (/docs, /status) and drops it only here, via a new
optional className on ThemeToggle, so two objects are not competing in a bar where one has
the job. The CTA also goes from 34px to 40px — it was sized to sit beside a bordered icon button
that no longer looks like one, and 34 was under the touch floor PRODUCT.md sets.
Keyboard focus is visible on the nav links, the wordmark and every CTA. It previously was not:
the links changed colour on hover and did nothing at all on focus.
The hero is an empty canvas again. It held five of the app's real card components scattered
around a blurred glass panel containing the copy, plus the app's own toolbar pinned along the
bottom. The intent was honest — those were the real components, so the page could never show a
mockup that had drifted from the product — but it gave the one heading on the page five things
to compete with, and put frosted glass behind the sentence a search engine, a payment-processor
reviewer and a first-time visitor all read first. The dot grid says "infinite canvas" on its own.
Now the headline gets the screen: 36→76px instead of topping out at 52, on a plain grid, with
a soft radial wash of the page background behind it so the dots fade rather than run through the
type. No panel, no border cutting the hero in half.
Dropping the card layer and the toolbar takes the landing page from 185 kB of first-load JS to
107 kB — they were most of what it shipped, to render furniture.
One type scale down the rest of the page. Body copy, feature text and FAQ answers were packed
into a 13.5–15.5px band, so every section below the hero read as one undifferentiated grey block.
The steps are now far apart enough to rank at a glance — 76 / 40 / 21 / 16.5 / 14.5 — section
padding grows with them, and the FAQ caps at two columns instead of setting its answers at forty
characters a line on a wide screen. Both grids collapse properly on a phone: a bare minmax()
floor does not shrink below its minimum, so the two-column FAQ would have scrolled the whole page
sideways on any handset 377px or narrower.
Added
@-mention people on the cards themselves, and they get told. Type @ in a text card or a
sticky and the picker opens; names stay lit after you stop editing, because a mention you can only
see while typing is one nobody reading the board notices. Mentions in a to-do task's title or
description, and in a poll's question or options, count too.
The bell now carries mentions alongside invites. Clicking one switches space and flies the
viewport to the card, selecting it — arriving at the right coordinates is not the same as being
able to tell which card you were sent to. Comment mentions are routed into the same bell; they have
always been detected (they drive the email digest) but until now reached nobody's notifications.
Only NEW names are announced. A card is saved on blur, on resize, on every remote echo —
re-announcing everyone named in it each time would make the bell useless inside a day. You are
never notified about yourself, and mentioning the assistant on a card notifies nobody: it has no
account, and that mention doesn't summon it either (that rule lives in chat).
Text that belongs to a source is excluded — a link's title, a repo's description, a tweet's
body. Nobody wrote @ada into a scraped page on purpose, and pinging a colleague because a fetched
article happened to contain it would be indefensible.
Notifications are a delivery record, not an event log: one row per (recipient, thing that
happened). Invites deliberately stay in their own table — the row is the fact there, and it
carries accept/decline semantics nothing else has. Duplicating them would mean two sources for one
badge count, which is the bug where the bell says 3 and shows 2. RLS makes the client-side write
safe: a row must name its author truthfully and address someone who is a member of the same space,
or a shared board becomes a way to send text to any account id you can guess. Reads are yours
alone, and Realtime honours that. Old rows are swept nightly — a bell is not an archive.
Notifying never blocks an edit: the card has already been written by the time it runs, and trading
the thing you did for the announcement of it would be the wrong way round.
Changed
Frames became real frames. Drag one and everything inside comes with it; right-click for
screen and paper sizes, or to export the frame as a PNG, JPG or WebP at 1×–4×.
Membership is geometric and snapshotted at drag start, not recomputed per frame of the
gesture — that is the difference between a frame that carries what it holds and one that vacuums
up whatever it passes over, dropping the cards it has already dragged clear of its own bounds.
Passengers move by an absolute offset from where they started rather than by accumulated deltas,
which drift, because React Flow reports positions that are already snapped. Nothing persists until
the drag ends, so one gesture is one undo entry rather than sixty. The innermost frame owns a card,
so dragging an outer frame leaves what belongs to an inner one alone.
Export works by re-rendering React Flow's single transformed viewport element with a transform of
our choosing — set it so the frame's top-left lands at (0,0) and the crop falls out of the
geometry. The frame's own dashed edge and title tab are dropped: a frame is a viewport onto its
contents, and Figma doesn't print the viewport either. Selection rings, resize handles and delete
buttons go with them.
The dialog states the exact pixel dimensions before you commit, because "2×" is a ratio and the
difference between a 2160px asset and an 8640px one is the difference between a usable file and a
browser that quietly hands back a blank canvas. Combinations past the canvas limit are refused up
front for exactly that reason — that failure is silent, so a limitation you're told about beats a
bug you have to diagnose. It also says up front that embedded videos, maps and Figma files come out
blank; that's the same-origin policy, not something this side can fix, and it's better said than
discovered in a blank rectangle.
Frame sizes are exact-match only. "Close to A4" is not A4, and a label that drifts as you nudge a
corner is worse than no label, because you stop trusting it exactly when it matters.
The focus timer moved out of the top bar and onto the board. It is a card now: movable,
resizable, and undeletable while it runs.
That last part is the whole point. A focus timer you can dismiss with a keystroke the moment it
gets uncomfortable is a decoration, not a commitment device. Pause it or let it finish and it
deletes like anything else — but until then the delete button is gone, the context-menu row is
disabled and says why, the Delete key puts it back, and it survives clear_all, because a rule
with an exception for the one command that deletes everything is not a rule.
It ticks in the last minute only, and the sound is synthesised rather than a file — which
matters because the last ten seconds need a different sound, not a louder copy of the same one.
Pitch, length and loudness all move together; a tick that only gets louder reads as the same tick
turned up. Twenty-five minutes of ticking is not a focus aid, it is an irritant you eventually
mute, which means it isn't there for the ten seconds it would have helped. Mute is per-device:
the clock is shared, but whether your laptop makes a noise is yours, and syncing it would mean one
person silencing the room.
The final ten seconds pulse the card — a breath under 2%, so it never nudges its neighbours — and
it respects prefers-reduced-motion, where the colour alone carries the state.
As a card it inherits everything. It works signed out, survives a reload, and syncs to everyone
through the machinery every other card already uses. So the Realtime broadcast channel it used to
run on, with its sequence numbers, its re-announce-on-join handshake and its late-joiner catch-up,
is deleted — about 130 lines gone. endsAt is an absolute instant, so a sync arriving a second
late is still the same instant; that is what made the swap free.
The top bar keeps a 30px launcher (it was a 100px countdown). There is deliberately no readout
there any more: two places showing one countdown is two places that can disagree, and the point of
putting the clock on the board was that it is somewhere you are already looking. Below 900px the
top bar is gone, so the phone tool launcher carries the entry point instead.
The assistant cannot start, stop, extend or cut short a session — there is no create_timer
and the clock fields are unreachable through update_card. It can rename and resize one. A focus
timer is a commitment somebody makes to themselves, not something to be started on their behalf by
a thing that misread a sentence.
Added
Polls you answer on the board. A question, 2–10 options, single or multiple choice, optionally
with a deadline. Lives in the toolbar's Text flyout and the right-click Insert menu.
Results appear once YOU have voted, not once the poll closes — the WhatsApp/X rule. It stops
the first answer anchoring everyone else without needing a reveal step somebody then has to
remember to perform. The bar fills the row behind the label rather than sitting beside it, so the
option keeps its full width and the row reads as filling up.
Percentages are over VOTERS, never over the sum of the counts. In a multi-choice poll those
differ, and a chart whose bars add up to 240% is a chart nobody can read.
Polls are anonymous by default. On a board where your manager can see the tally, a named poll
and an anonymous one ask genuinely different questions, and the safer of the two should not need to
be chosen deliberately. Clicking your own answer takes it back — a poll you cannot un-answer is a
trap, and mis-tapping one option of five on a phone is not rare. An abstention deletes the key
rather than storing an empty array, because keeping it would leak that you opened the poll at all.
The assistant can ask, never answer. There is no vote action, and votes, question and
options are all unreachable through update_card — a poll records what the people in a space
think, and a model with a thumb on that scale makes the card worthless. Editing the question after
votes are in would silently change what every existing vote meant, which is worse than living with
a typo.
Votes ride in the card's own data, which is the trade-off worth naming: cards are editor-write,
so a space viewer cannot vote. A votes table with member-write RLS would fix that at the cost of
a fourth per-space sync stream. Signed out, the voter key is a random per-device id in
localStorage — so a local board counts devices, not people. That is the honest limit of a board
with no accounts on it; the alternative was a fingerprint, which is a much worse thing to build
than an undercount.
To-do lists you drag tasks between. A new card kind, in the toolbar's Text flyout and the
right-click Insert menu. Tap a task for a full back — description, checklist with a progress
bar, colour label, due date and assignees drawn from the space's members.
One card is one LIST, not a whole Trello board, and that is the design. The board is the
space: you place "To do", "Doing" and "Done" wherever they make sense and drag tasks between them
across open canvas. A single card holding every column would have been a window onto another app
sitting on the canvas — scrolling inside a thing that already pans and zooms — with its own layout
rules competing with the board's. The cost is honest: there is no board object to name, move or
export as a unit. Frames are where that would be built.
Dragging is pointer-based, not HTML5 drag-and-drop, because native DnD does not exist on touch
and this had to work on a phone. The two ends of the gesture live in different React Flow nodes, so
the drag state sits in a tiny module store that only to-do cards subscribe to — lifting it to the
canvas would re-render every card on the board on every pointer move. The drop target is resolved
from the DOM (elementFromPoint), because the question is "what is physically under the finger"
and the browser is the only thing that actually knows. The ghost that follows your pointer writes
transform directly rather than re-rendering sixty times a second.
A press under 6px is a tap, which opens the task — the only way into the detail panel on a
phone, where there is no hover and no right-click. The lifted task stays in place, faded, instead of
being removed: pulling it out collapses the list under your finger and every target you were aiming
at moves. Dropping at the very end of the list it came from lands at the end, which needs the drop
index compensated for the task's own removal — the single most noticeable reorder bug there is.
Clock-wise, a done task is never overdue: a finished task glowing red is a false alarm, and a
list of false alarms is a list nobody reads. Due dates are days at local midnight, not instants, so
"12 Aug" set in Accra still reads 12 Aug in Los Angeles.
The assistant gets create_todo — one op per column, tasks as plain titles. It is deliberately
not allowed to set due dates or assignees, and update_card cannot reach the tasks at all:
a patch is a flat bag of scalars and a task is a record, so anything expressive enough to edit one
would be a second vocabulary nested inside the first.
Every card now says who made it and when. Right-click any item and the menu opens with
"Added by Clip AI · 4 min ago · 7 Aug 2026, 14:32" before it offers you a single action.
The reason is that the assistant got good enough to be a problem. An AI-written sticky and one
you wrote yourself three weeks ago are the same yellow square, and on a board where the assistant
has been busy that is a board you cannot fully trust — you end up re-deriving your own decisions
because you can't tell which ones were yours. Three author kinds, not a nullable user id: a card
written over MCP says "Added by a connected app", never the name of the account whose token it
held, because a person asleep at 3am did not place that card and blurring the two defeats the
whole point.
Provenance is opt-in per caller, and that is deliberate.applyOps stamps nothing unless it is
told who to credit, so a caller that hasn't identified itself cannot have "a human did this"
written on its behalf — the failure mode of a default would be silently laundering every AI card
into a human one, which is the exact thing this field exists to prevent. Duplicates are re-stamped
rather than inherited for the same reason: ⌘D on someone else's card must not forge their name onto
your copy, in either direction.
Deferred creates (a link to unfurl, a stock photo to fetch) resolve through the same add* helpers
a person's drag-and-drop uses, so the AI's link cards used to come back stamped with the name of
whoever asked for them; runAt now carries an author alongside the placement override.
Cards written before any of this existed backfill their timestamp from the cards.created_at
column and show no author at all, rather than inventing one.
Referrals: share a link, every signup that confirms an email adds Clip AI credit. A per-user
code (/?ref=ABCD2345) in Settings → Plans, and 100k Clip AI tokens for each referral that
qualifies. No points, no redemption, no tiers: credit is the only thing a referral buys, so a
balance and a Redeem button would be ceremony standing between someone and the single outcome
available. The bonus lands by itself and the panel says how much is left.
The reward was plan time for about an hour before this landed, and switching away deleted a
whole coupling. Plan time was a second source of entitlement, so private.plan_for_user and
getEntitlement both had to fold it in and agree, forever — a class of bug where a user reads
"Personal" in Settings while a Postgres trigger enforces Free caps. plan_for_user is now reverted
to exactly its 20260725000100 definition, entitlements.ts is back to knowing nothing about
referrals (89 lines removed), and referral_grants is dropped. A test asserts the coupling stays
gone rather than merely describing that it is.
AI credit is also the honest reward: it is the one cost this product already meters per user in
real units, so the bonus is paid in the currency the operator actually spends.
The bonus is DERIVED, never granted — qualified referrals × 100k — so no row is written when
someone qualifies, nothing can double-grant, and a wrong balance is always reconstructible from
public.referrals. The only stored number is how much has been spent.
It is a wallet, not a bigger allowance, and that distinction is the design. The plan allowance
is a rolling window that refills; folding the bonus into the limit would hand it back every window,
making one referral worth unlimited tokens over time. It is spent only once the window allowance is
exhausted — the window refills by itself and the bonus does not, so burning earned credit while
free allowance sits unused would be the wrong way round.
Anti-abuse is in the schema, not just the route: an account can be referred exactly once, ever
(unique (referred_user_id)), self-referral is a check constraint as well as an API check, claims
expire 7 days after signup, spending runs inside a per-user advisory lock and clamps to what is
actually left (the AI request has already been served, so under-charging is the only acceptable
failure). Both RPCs sit in public because PostgREST cannot see anything else, with execute
revoked from anon/authenticated and granted only to service_role.
Qualification has two paths, one rule: an auth.users trigger promotes a pending referral when
email confirmation lands, and claimReferral marks it qualified on arrival for OAuth signups —
which are already confirmed at INSERT, before the referral row exists, so the trigger can never
fire for them.
?ref= is captured by middleware into a cookie rather than a client effect, because a share link's
whole job is to reach someone who has never loaded the app; the code has to survive reading the
landing page and signing up days later. It is deliberately not in APP_INTENT_PARAMS, so a
referral link lands on marketing rather than a blank canvas — a test fails if that ever changes.
Fixed
Connecting an MCP client with "create spaces" ticked always failed. The consent screen ended
on "We couldn't save that — nothing was shared", with no way to tell why. The cause was a
constraint that never learned a scope: 20260727000100_mcp_oauth.sql limited
mcp_grants.scopes to the three scopes that existed when it was written, and
20260730000100_mcp_created_spaces.sql added spaces:write to MCP_SCOPES and the
created_by_grant column without widening it. Any consent including spaces:write then
failed both halves of the check — unknown array element, and a cardinality ceiling of 3 that
spaces:write plus the two scopes it implies already sat at. saveGrant read the error, returned
null, and the route rendered save_failed. Ticking the option had never worked once in
production and leaving it unticked always had, which is what made it look intermittent.
Nothing was insecure: the constraint is a backstop behind lib/mcp/oauth.ts's own validation and
it failed closed, refusing a legitimate grant rather than admitting an illegitimate one.
The OAuth store now logs why a grant failed to save.saveGrant and issueAuthCode both
discarded the Postgres error and returned null, and the caller turns null into a screen that
deliberately tells the user nothing — correct for them, and the reason this bug was invisible for
a week. The error code is the whole diagnosis (23514 is the scope constraint, 23503 a client or
user row that vanished), so it is now logged before being discarded.
tests/mcp-scope-constraint.test.ts parses the migrations and fails on drift. The scope list
lives in two places TypeScript cannot reconcile — MCP_SCOPES and a SQL check constraint — which
is the same shape of problem tests/accent-tokens.test.ts and tests/billing-enforcement.test.ts
already guard, and it gets the same remedy. It reads the last migration that defines the
constraint rather than the first, since migrations apply in filename order; reading the original
table definition would have passed for the entire life of the bug and failed on the fix.
Added
A landing page at /, for people who have never opened ClipSpaces. The root URL was the app
and nothing else: a first-time visitor got a title, a "Connecting…", then a blank canvas. Nothing
said what the product was, and /pricing was not linked from / at all — those links lived
inside the avatar popover and the docs sidebar. Paddle's domain review failed on exactly that:
"we weren't able to confirm that your core offering is a digital product." They take on the
legal and tax liability for every transaction, so a human has to be able to identify what is being
sold from the front door.
The page states plainly that ClipSpaces is a web app, shows what ships (canvas and card kinds,
local-first with no account, Clip AI including the unmetered bring-your-own-key path, sharing with
live cursors, in-place comments, cross-device sync and Notion), carries the three plans at their
real prices, answers the five questions a buyer actually asks, and puts Terms · Privacy · Refunds
· Pricing in a footer. A SoftwareApplication JSON-LD block says the same thing to anything
reading the page that isn't a person.
Returning users never see it. A middleware rewrite serves the landing at / only when the
request carries no app intent and nothing says this browser has been here — so the URL never
changes and no share link, invite email, billing callback or bookmark moves. The decision lives in
lib/landing-gate.ts as a pure function over plain data, because the rule that decides whether a
shared ?space= link reaches its board is not a rule to leave untested; tests/landing-gate.test.ts
covers every app-intent param individually. The signal for "been here before" is a cs-seen
cookie written on app mount — a cookie only because middleware cannot see localStorage, holding
nothing but the fact that this browser has been here.
The hero is a real space. It fills the first screen, and every card on it is the same
component the canvas renders — StickyCardContent, LinkCardContent, TextCardContent,
ShapeCardContent and StickerCardContent from components/cards/*, fed real Card objects —
under the app's own Toolbar, below a header shaped like the app's top bar. Nothing is a mockup
of the product; it is the product, arranged to say what the product is. A screenshot would have
gone stale the first time the accent changed and could only ever show one theme; a hand-drawn
imitation in CSS (which this briefly was) looks right until the day a card is redesigned.
Two departures, both deliberate. The headline is a real <h1>, not a TextCardContent — that
component renders a 14px <p>, correct on a canvas and wrong as the one heading on a page a
search engine and a payment reviewer have to read — so the copy panel reuses the card look while
staying real markup. And the cards take no pointer events: the real components are editable,
and letting someone type into a text card with no board behind it means silently dropping what
they typed. The chrome stays live, so the page still feels like the app.
The plan cards are likewise the same component/pricing uses (components/PlanCards.tsx,
extracted from that page), so the two surfaces cannot quote different prices.
A public pricing page, terms of service and refund policy — the four pages a payment processor
verifies you against. Paddle's business verification asks for a pricing page and links to terms,
privacy and refunds, and we had exactly one of the four. /privacy already existed; /pricing,
/terms and /refunds are new, and all four are now reachable from the docs sidebar (a new
Legal group), the docs footer, the ⌘K docs search, the space menu in the top bar and the avatar
popover — which is the only chrome at ≤600px, so its link row now wraps instead of overflowing.
The pricing page renders from lib/billing/plans.ts, not from prose. Prices, storage caps,
collaborator limits and AI allowances are read out of the plan catalog, so the published price and
the price the checkout charges cannot drift — the thing a hand-written pricing page always
eventually gets wrong. The rail table reads from lib/billing/rails.ts the same way.
It also closes the disclosure gap docs/PRICING.md §4 has been carrying. A buyer on the
Paystack rail is charged cedis against a dollar price and that was stated nowhere before the
button; the page now says it plainly, with the actual converted amounts ($6 → GH₵75,
$14 → GH₵175) and a warning that a non-Ghanaian bank will convert again and add its own fee.
The refund policy is 30 days, no questions asked, deliberately matching Paddle's own buyer
terms. Paddle is merchant of record on the international rail, which means it can grant a refund
whether or not we promised one — so promising anything shorter would only publish a page that
contradicts the checkout the buyer actually agreed to. Same window on both rails, so the policy is
true for every customer regardless of which processor took their money. The page is explicit about
who refunds what: Paddle refunds its own transactions in the currency charged, tax included; we
refund Paystack card and mobile-money payments in cedis.
Identity lives in one file.lib/legal.ts holds the operator, contact address, refund window
and effective date, and all three pages render from it — a verification that cross-checks the
entity named on the site against the entity on the merchant account should not be able to catch
two pages disagreeing because each spelled it out by hand.
OPERATOR.legalName must match the name on the Paddle
account character for character. The operator is described as "an individual", not as a sole
proprietorship — there is no registration behind the product, and claiming one on the pages a
processor reads during verification is both untrue and the fastest way to fail that check.
Paddle, as a second payment rail for everyone outside Africa. Paystack was losing every
international sale and we could not see it happen: international card acceptance is not enabled on
the merchant account — it is a request to Paystack support, not a dashboard toggle, and it was not
granted — so a non-African card is declined on Paystack's page, before this app hears anything.
The buyer meets a failure we can neither explain nor instrument. That, not fees, is why there is
now a second rail.
Who goes where.lib/billing/rails.ts routes on the edge's country header: Ghana, Nigeria,
Kenya, South Africa, Côte d'Ivoire and Egypt stay on Paystack (cedis, and the only rail with
mobile money); everyone else goes to Paddle, which charges the advertised USD price and is a
merchant of record, so cross-border VAT/GST becomes Paddle's liability rather than ours. An
unknown country resolves to Paddle on purpose — no header means no edge in front of us, and
guessing "local" for someone we can't place declines their card and loses the sale, where guessing
the other way only costs a Ghanaian the mobile-money option. Settings → Plans carries a switcher
that hands it straight back, because an IP is a guess about where somebody is and only they can
see it's wrong.
The rail is resolved server-side in lib/billing/rail-resolve.ts, called by both
/api/billing/subscription (so the panel renders the right currency) and /api/billing/checkout
(so the charge matches it) — never separately, or the buyer reads one price and pays another. A
client can express a preference; it cannot pick the processor.
No migration. Every billing table was already provider + opaque provider_* text, and
ENTITLING_STATUSES already listed trialing and past_due. subscriptions,
billing_customers, billing_events and payments took the second rail unchanged — the bet the
original schema comment made, paid off.
New: lib/billing/paddle.ts (plain fetch, no SDK, deliberately the Paystack adapter's shape),
lib/billing/paddle-grant.ts, app/api/billing/paddle/webhook, and app/checkout. That last one
exists because Paddle Billing has no hosted checkout page — that was Paddle Classic; in
Billing, checkout.url is your payment link plus ?_ptxn=, and it has to run Paddle.js. So
Plans still just redirects, exactly like Paystack; it redirects to us first, and Paddle's script
loads on that one route from their CDN rather than entering the dependency tree.
The two webhooks stay separate routes on separate secrets — Paddle signs ts:body with
SHA-256 and a per-destination key, Paystack signs the body with SHA-512 and the API key, and one
handler would have to sniff which scheme applied before it had verified anything. Only Paddle's
signature carries a timestamp, so only that route rejects a correctly-signed body replayed from
outside a 5-minute window. Its event_id is stable, so it keys the idempotency ledger directly,
namespaced paddle:.
Changed
Settings → Plans states the currency before the button, not after it. The cedi chip appeared
only on the mobile-money path, which quietly implied the card path was billed in dollars. It never
was — every Paystack plan is created in GHS. The chip now shows on every plan card on that
rail, with "Charged in cedis at the rate shown on each plan. Your bank may convert again."
underneath. Buyers routed to Paddle pay the advertised USD outright and see none of it. The
sandbox banner also prints the right test card per rail, which is otherwise an hour of debugging a
card the other processor has never heard of.
docs/ADMIN_WIRING.md records the second ledger writer. The payment-ledger hook is now two
webhooks writing one row shape, and the console consumes three things from it that only matter
once there are two rails: provider (which API can be asked about a row — the console filters its
reconciliation reads by it rather than trusting the two providers' reference formats to stay
disjoint), currency (GHS and USD, so revenue_by_month returns two rows a month and nothing may
sum them), and channel in each rail's own vocabulary. The refund gap is restated as covering
both rails — Paddle's equivalent event is adjustment.created, keyed by transaction_id — and
the display-currency gap is now half closed: recordPaddlePayment writes both columns, while
recordPayment on the rail where the USD/GHS gap is real still writes neither.
Added
An admin console, in its own app.clipspaces-admin/ — a separate Next 16 + Tailwind v4 +
shadcn project on the same Supabase project, holding the service-role key and deployed to its
own domain. Twelve surfaces: dashboard, product analytics, subscriptions, payments and
reconciliation, pricing health, users and moderation, support inbox, bug triage, email log, shared
API keys, audit log, operators.
Separate rather than a route group inside this app, because the console needs the service-role key
and this app must never hold it. Access is an email allowlist (admin_users), same reasoning
as beta_testers: you grant access to a person before they have an account row. Two independent
checks in order — proxy.ts verifies a session exists, requireAdmin() verifies that session's
email is an operator, using the service role because the table is invisible to every client role.
Roles are owner > admin > support, and Support exists so somebody can work the inbox without
ever being able to reveal a key or issue a refund. Every mutation writes to an append-only
admin_audit_log.
The design language is ported, not approximated: the palette is copied verbatim from
globals.css, theming is the same [data-theme="dark"] attribute, and the fonts are the same
self-hosted files under the same --font-outfit variable name.
Bug reports live in Postgres. They were Notion pages and nothing else, which made a report
something you could read and not something you could triage — no joining a reporter to their plan,
no "how many open Critical bugs this week", and every list view a round-trip against a
rate-limited service. public.bug_reports is now the record; the Notion push is a best-effort
mirror that runs after the row is committed and stores its notion_page_id. Bug reporting no
longer requires Notion to be configured at all — it used to 503 without a token, and now a
missing token costs a mirror rather than a report.
A payment ledger.subscriptions held current state and billing_events held processed ids
whose rows are explicitly disposable, so nothing could answer "how much did we take last month" or
"why does the Paystack payout disagree with what we sold". Fees are usually the answer to the
second one, and you cannot see fees unless you record them per transaction at the time.
public.payments is written by the webhook alongside the grant — before deciding whether to
throw, because a charge we could not attribute to a user still took money, and those are exactly
the rows worth looking at. Upserted on the provider reference, so a redelivery cannot double-count
revenue. The console reconciles it against Paystack and can import anything missing.
Refund events are not handled yet, so refunded_minor is always zero and the net-revenue
figure overstates by whatever has been refunded. docs/ADMIN_WIRING.md has the refund.processed
case that closes it; until then, treat net as gross-minus-fees and reconcile refunds by hand.
The base Clip AI key can live in the database.hostConfig() resolves
shared_api_keys['ai_base'] → ai.config.json → env vars, so the key that pays for every free
user's AI can be rotated or killed in seconds from the console with no redeploy. Env stays as the
floor deliberately: an unreachable database must not take Clip AI down with it. Migration
20260706000300 dropped this table with the note that an admin panel wasn't needed; there is one
now, which changes the calculus.
An AI usage ledger.ai_usage is a per-user counter that overwrites itself as its window
rolls — correct for "may this request proceed", useless for "what did last month cost". recordTokens
now also appends one row per call to public.ai_usage_log with the model, the surface and which
shared key paid. Users on their own key log with a null slug: counted in volume, excluded from
spend.
An email log.sendEmail returned a rich result and threw it away, so nobody could answer
"did that invite actually go out" — the first question asked whenever someone says they never
received something. Every attempt now lands in public.email_log, failures included.
Moderation flags.public.user_flags — blocked, AI-suspended, throttled — with an expiry the
database honours, so a temporary measure nobody remembers to lift isn't a permanent one. AI
suspension is enforced in the AI route before anything is paid for, and only against the host key:
someone on their own key costs us nothing, so suspending them would be punishment for its own
sake. blocked is stored but not yet enforced — see docs/ADMIN_WIRING.md.
/api/analytics — a first-party events endpoint for the handful of funnel steps the tables
genuinely cannot see (a pricing view that didn't convert, a checkout abandoned at the redirect).
Names are validated against a closed list, because a free-text event name is how a table ends up
with 40,000 distinct names and no query over it ever works again. Everything else the console
reports — signups, activation, retention cohorts, revenue — is derived from data we already own
exactly, so it can't be blocked by an ad blocker or sampled away.
Pay by mobile money. Card was the only option, and not because we chose it: attaching a
Paystack plan makes a transaction a subscription, a subscription needs a reusable authorization
to charge again unattended, and mobile money can't produce one — every MoMo debit needs the
customer to approve a prompt on their handset. Paystack therefore narrows the checkout to card the
moment a plan is attached, whatever channels you ask for. In a market where most people pay by
MoMo, that isn't a small gap.
So the MoMo path doesn't subscribe. It charges once for a fixed term and the term lapses:
checkout initializes with an explicit amount and no plan, the webhook grants the period, and
getLimits() already drops a user to Free past current_period_end — no new revocation
machinery, the existing backstop just does its job. Both methods sell the same plan at the same
price for the same length; only the renewal mechanism differs.
Three things this forced into the open. The card path can trust the plan code because it lives in
our dashboard where no request can touch it; a one-off has no plan code, so the webhook verifies
the money — the captured amount must equal what that plan and cadence cost, in the currency we
charge in, or nothing is granted. "Manage billing" now renders only when there's a real Paystack
subscription behind it (manageable on the entitlement), because a MoMo term has no card to
replace and no renewal to cancel, and the portal would 404. And since nothing renews itself, a
daily sweep (/api/cron/term-expiry) emails a warning before a term runs out, claiming the send
with a conditional update so two overlapping sweeps can't double-mail and releasing the claim if
delivery fails. It records the period end it warned about rather than a boolean flag, so a new
term is self-evidently un-warned and nothing has to remember to reset.
A cancelled card subscription is deliberately excluded from that sweep — the flag looks
identical, but they chose to leave, and "your plan is ending!" after someone cancels is a nag.
A test-key upgrade can now actually be completed. The webhook is the only thing that grants a
plan, and a webhook cannot reach localhost — so the sandbox couldn't be walked end to end: you
paid with the test card, came back, and the app looked exactly as before. /api/billing/verify
closes the loop by asking Paystack directly about the one transaction you just completed. It's on
automatically whenever the key is sk_test_…, and BILLING_VERIFY_ON_RETURN=1 enables it with a
live key too.
Not a trust hole, and worth being precise about why: the transaction is read from Paystack
with our secret key, so a reference typed into the URL bar proves nothing; the reference must
belong to the session's own user, because Paystack will happily confirm a stranger's successful
payment; and the grant is the same code the webhook runs. That last point is why the grant moved
out to lib/billing/grant.ts — two callers applying "the same" rules from two copies is how
one path quietly starts granting something the other wouldn't.
Settings → Plans now also shows a Test mode banner with the test card details whenever the key
is a sandbox one. The worst outcome here is someone walking the flow, seeing "Current" on a paid
plan, and believing they were charged.
/?settings=plans opens Settings on a tab. The renewal email needed somewhere to point, and a
CTA that lands on a blank canvas and leaves you hunting through menus is a CTA nobody follows. The
param is stripped with replaceState on arrival so a refresh doesn't reopen the modal.
Changed
Prices are quoted in dollars and charged in cedis. The merchant account has no USD settlement,
so Paystack rejected every checkout at /transaction/initialize before a card was ever entered —
which is what the generic "Couldn't start checkout" was hiding. Rather than re-denominate the
product, the currency is now split: DISPLAY_CURRENCY (USD) is the advertised price and the
only thing the Plans tab renders, CHARGE_CURRENCY (GHS) is what Paystack processes. The Plans
tab is untouched — still $6 and $14.
The conversion happens once, when a plan is created in the Paystack dashboard, not at checkout:
a Paystack plan's amount and currency are fixed at creation, so USD_TO_GHS is baked in per plan
and only moves when a plan is re-created. chargeAmountMinor() is the single authority on what
those amounts should be, rounded to a whole cedi so a statement reads GH₵75 rather than GH₵74.83.
Tests lock the cents→pesewas arithmetic (the 100x bug), the whole-cedi landing, and the fact that
rounding doesn't quietly erode the two-months-free yearly discount.
Known gap, deliberately not papered over: a buyer sees $6 on our page and a cedi amount on
Paystack's, and nothing yet says so before the button. Tracked in docs/PRICING.md §4.
Fixed
Every primary button in the app was still painted the retired ink.--sd-primary, the token
shadcn's default button variant fills with, held #37352f / #5a5852 — the warm near-black the
accent used to be. Its comment even read /* --accent */: it was written when that was true,
and the blue-accent change never reached it. So the app's most prominent CTAs stayed near-black
for a day after the brand went blue, with nothing failing and nothing warning, because the two
declarations live in different blocks of the same stylesheet.
It's now --accent-solid — the AA-safe fill, not --accent, since primary is only ever used
behind text and white on --accent is 3.92:1. applyAccent() and the boot script in
app/layout.tsx both set it too, so a custom accent recolours default buttons instead of leaving
them on the stylesheet's blue. tests/accent-tokens.test.ts reads the real stylesheet and
fails if the two drift apart again, if either theme regresses to one of the two ink values, or if
the fill stops clearing 4.5:1 against white.
The Studio plan card read as disabled. It shared the Free card's styling — muted ticks, muted
body, flat elevated panel, secondary button — which in dark mode is indistinguishable from a
disabled control, on the most expensive tier. There are now three treatments rather than two: the
paid-but-not-promoted card sits on the surface colour with a real border, accent ticks run down
every paid feature list, and both paid tiers get the filled accent CTA. Hierarchy between them
is carried by the card — Personal keeps its tint, ring and Sparkles — rather than by crippling the
one control that takes money.
The top bar told every user they were on Free. The plan badge beside the space name was the
literal string Free — not stale state, not a caching bug: nothing had ever read the plan. It now
renders the real one through a new usePlan() hook, and paid plans get the solid accent so the
badge reads as a status rather than a nudge to upgrade.
The hook keeps one module-level cache with a subscriber set rather than a fetch per component,
because the badge and Settings → Plans would otherwise each hit the endpoint on every open and —
worse — could disagree, having read at different moments. It's refreshed after a confirmed
payment and whenever Settings opens or closes, cleared on sign-out so the next person to use the
browser doesn't inherit the last account's plan, and held back entirely until the first read
lands: flashing "Free" at somebody who pays is the same bug with a shorter duration. It fails to
Free, since over-reporting a plan is the version that gets noticed by the wrong person.
"You're upgraded" could be a lie, twice over. A completed test payment reported success and
left the user on Free. Two independent causes, both worth naming:
A failed database write looked like a successful one.supabase-js returns { data, error }
rather than throwing, so an upsert refused by a constraint, a policy or a missing column fell
straight through to granted: true — and the webhook's try/catch never fired, because nothing
was ever thrown. That is the worst shape a billing bug can take: money in, no plan out, a 200 back
to Paystack so it never retries, and no alarm anywhere. Every grant write is now checked, logged
with its Postgres error code, and reported as a failure. The webhook turns that one reason — and
only that one — into a 500 so Paystack redelivers; "not ours" and "unknown customer" still 200,
or Paystack would retry them forever.
The panel read the plan before the payment was confirmed. Returning from Paystack opened
Settings → Plans and verified the payment concurrently, so the panel's fetch usually won the race,
rendered "Free", and then a success toast landed on top of it. The confirmation now completes
first and the panel opens after, so its first read is of granted state. A payment that can't be
confirmed says so instead of claiming success.
The cedi amount on a plan card was unreadable. It rendered at --text-3, the tertiary tone
this app uses for disabled and de-emphasised text, so the one number that says what actually
leaves your wallet looked like a footnote under the dollar price it contradicts. It's now a tinted
chip at --text-2 — a peer of the price, not fine print beneath it.
Checkout failures are now diagnosable./api/billing/checkout returned a flat "Couldn't start
checkout" for every Paystack rejection while discarding the reason, so a merchant-account problem
(USD not enabled, plan code from the wrong mode, account not activated) was indistinguishable from
a network blip. The reason is now logged server-side; the client response is unchanged, because
Paystack's message can name account configuration and isn't the buyer's business.
Added
docs/USE_CASES.md — fourteen workflows the app actually supports, for students and
professionals.PRODUCT.md still describes a solo tool used at a desk with no account, which was
true of the first version and is no longer the whole truth: there are accounts, realtime, roles,
comments, an assistant and an MCP server now, and nothing written down said who that widened the
audience to.
It is written for the reader, not the builder — the audience is a student or a client, so the
prose says "changes appear as they happen" rather than naming Realtime, and "a locked card nobody
else can read, not even us" rather than describing envelope encryption. Each use case is tagged
with what it costs to get going (nothing at all / same Wi-Fi / an account / AI switched on),
because "works with no account" and "needs an API key" are very different promises and a list that
blurs them oversells the first run.
The grounding survives in a collapsed For the team section at the foot: every claim mapped to
the file that implements it, so a use case that stops being true breaks visibly rather than quietly
becoming marketing. One document, two readers, no second copy to drift. A short section on what the
tool is not good for (long-form writing, dated task management, spreadsheets, org-scale
permissions) is there for the same reason.
Mirrored to Notion as sub-page 11 under the handover hub.
Changed
The beta is open: everyone who signs up is a beta tester. Membership was a hand-picked
allowlist (public.beta_testers), which is the right shape for a closed cohort and the wrong one
for where the product is — the whole thing is in beta, and the only people it was closed to were
the ones actually using it. A signed-in person who hit a bug had nowhere to put it: the report
button didn't exist for them, so the one piece of feedback a beta exists to collect was the one
thing the gate blocked. public.beta_flags.open (a single-row table, id boolean primary key check (id), so a second row is a constraint violation rather than an ambiguity) now says the beta
is open to anyone signed in. Closing it is one update … set open = false from the dashboard —
no redeploy, no code change — and the allowlist is deliberately left populated underneath, so a
named cohort keeps its access on the far side of that switch.
The flag lives in Postgres rather than an env var because is_beta_tester() is SQL and can't read
the app's environment; one switch both callers agree on beats two that can disagree. And the rule
itself now lives in exactly one place, is_beta_tester_email(): the bug-report route used to
re-implement half of it as its own beta_testers lookup, which is precisely how a UI that shows
the button and a server that rejects the submission drift apart. That function answers about an
arbitrary email, so it is granted to service_role only — exposed to clients it would be an
allowlist enumeration oracle. The browser's door is unchanged: is_beta_tester() reads your own
JWT and can only be asked about you, and it keeps the auth.uid() is not null check, because an
open beta is open to people who signed up, not to anonymous visitors.
Migration 20260801000100_open_beta. Not yet applied — run it against the project before this
ships, or the RPC the route calls won't exist.