Added
-
Plan limits are now enforced — and live. Applied to the hosted Supabase (
clipspaces) viasupabase db pushon 2026-07-26; the storage trigger onstorage.objectscreated successfully rather than hitting the ownership fallback. The caps indocs/PRICING.mdhave been declarative since billing landed — priced, rendered in Settings → Plans, resolvable throughlib/billing/entitlements.ts, and applied by nothing. A Free account could create unlimited spaces.20260725000100_plan_enforcement.sqlcloses that.Why Postgres and not a route. Cards, edges, drawings and comments are written straight from the browser through the Supabase client under RLS; there is no server route to hang a check on. So the check lives where the write lands —
before inserttriggers onspaces,space_members,space_invites, vaultcards, andstorage.objects. AI tokens, frontier models and Notion stay route-side, where a route does exist.The ladder now exists twice, which is the one real cost of this design:
lib/billing/plans.tsprices it and a newprivate.plan_limitstable enforces it, because a trigger cannot import TypeScript.tests/billing-enforcement.test.tsparses the migration and asserts the two match field by field — change a number in one place and the suite fails until you change the other. It also pins the SQL port of the entitlement resolver (entitling statuses, the 3-day grace) to the TypeScript one.Nothing is ever deleted to fit a cap. An account already over the space cap keeps every space; the ones beyond it go read-only — oldest-first-wins, ordered by
(created_at, id)so the order is total and which spaces are writable can't flip between calls. They still open, read and export; the space list marks them Read-only andcanEditgoes false so the tools grey out rather than letting someone edit into a wall of silently discarded writes.DELETEis never blocked, since deleting is how a user gets back under the cap.Existing collaborators are never evicted — only the next join is blocked. It's the one cap whose victim would be a third party who did nothing wrong.
Two subtleties worth recording. Members and pending invites are counted together, or you could invite twenty people and let them accept one at a time, each insert passing a check the others weren't in. But the invitee's own invite is excluded when they accept it, because
claim_space_invitesinserts the membership while the invite row still exists — counting both meant the last seat on a plan could never be filled and an invited person was permanently locked out of a space they'd been invited to.Every rejection raises through one helper with SQLSTATE
CS429and a machine-readableclipspaces.limit:<cap>:<value>message.lib/billing/limit-error.tsis the only thing that parses it, and it returns null for anything else — a database outage must never render as a paywall. -
Hitting a limit tells you what to do about it. Rejections surface as one toast carrying an Upgrade button that opens Settings → Plans, instead of a silent failure or a raw Postgres message.
useToastgains an optional action (any toast may carry one, deliberately singular; actionable toasts also linger 7s instead of 3.8s, since the default isn't long enough to notice a button and reach it). Because the rejections surface insidelib/supabase/*.ts— non-React modules four layers below anything that could render — the data layer emits on a small bus (lib/billing/limit-bus.ts) thatUpgradePromptProvidersubscribes to, rather than threading anonLimitcallback through every signature on the path. The bus throttles to one toast per cap per 10s, or a locked board would bury the screen in identical messages every time the debounced card sync retried.
Changed
- Clip AI's token allowance is per-plan.
lib/ai/credits.tsresolved a single deploy-wideAI_CREDIT_LIMITfor everyone; it now reads the caller's plan throughcreditPolicyFor()— Free 50k/day, Personal 1M/month, Studio 5M/month.AI_CREDIT_LIMITandAI_CREDIT_WINDOW_HOURSsurvive as the self-host override: an operator with no billing wants one budget for everyone, so setting them pins every user to it. Own-key users remain unmetered — they cost the operator nothing. - Frontier models on the host key are Studio-only. New
isFrontierModel()classifies by a deny-list of economy models rather than an allow-list of premium ones, so a model we haven't heard of costs the operator nothing by default instead of being served to everyone until someone notices the bill. The host's own configured default is always exempt — whatever the operator runs as the base model is the base model. A gated request is refused rather than quietly downgraded: answering as a different model than the one the user picked is worse than saying no. Checked before the credit gate, so the error names the real reason. - Notion sync requires a paid plan. Gated on
importandexport, and onconnectso a Free user isn't sent through an OAuth dance the plan can't finish.statusanddisconnectstay open — a downgraded user must still be able to see and remove an existing connection.
Fixed
- The token-usage bar had no visible track in light mode. Settings → AI drew the meter's
unfilled track in
--bg-input, which in the light theme is the same#f1f0edas the card's--bg-elevatedbehind it — so at low usage the bar read as a floating stub with no scale to judge it against. The track is now--border-med, a translucent ink that separates from the card in both themes. - @-mentions in comments never appeared. The suggestion menu was an absolutely-positioned child
of the thread card, which sets
overflow: hidden— on a brand-new comment the composer is the card's topmost element, so the menu rendered above the card's edge and was clipped away entirely. Typing@looked like a dead key. It now renders through a portal positioned off the composer's viewport rect (components/comments/mentions.tsx), so no ancestor can clip it, and it flips below the field when the thread sits near the top of the screen. The mention query also runs to the end of the line instead of stopping at the first non-word character, so people with a space in their name ("Ada Lovelace") are reachable. - A stored mention was inert.
mentions[]was written into the comment'sdatablob and never read back — the body rendered as flat text. Mentions now render as accent chips, with your own highlighted in solid accent so being mentioned is visible at a glance. - Invited-but-not-joined people looked like a broken feature. Only rows in
space_memberswere ever mentionable, so someone you'd just shared a board with simply wasn't in the list. Pending invites now appear in the picker, greyed out and labelled invite pending (they have no account yet, so there's no user id to store).space_invitesSELECT is loosened from owner-only to any space member so every member sees the same list; writes stay owner-only. - The AI chat scrolled in from the top on every reopen. The scroll container lives inside
{open && …}, so closing the panel destroys it and reopening mounts a fresh one atscrollTop: 0— butscrolledChatRefsurvived the close, so the pin-to-bottom effect read "same chat, already settled" and smooth-scrolled from the top, which looked like the history was lazily loading in. The ref is now cleared when the panel closes, so a reopen lands at the newest turn instantly.
Added
-
Paid plans — Paystack billing (sandbox). Settings → Plans is live instead of a "Coming soon" tease: it renders the catalog in
lib/billing/plans.tsagainst the caller's real subscription, with a monthly/yearly toggle, and hands off to Paystack's hosted checkout to upgrade or its hosted Subscription Preferences page to change a card / cancel. Test mode is just a key swap — setPAYSTACK_SECRET_KEY=sk_test_…plus the fourPAYSTACK_PLAN_*codes and it's a sandbox; with no key configured, billing is off and the tab degrades to the old read-only catalog.Provider choice is forced by entity domicile, not preference: Stripe doesn't onboard merchants in most of Africa, so Paystack (which Stripe owns) is the rail. Every billing table is therefore provider-neutral —
provider+ opaqueprovider_*_id— so adding a merchant-of-record later for cross-border VAT is a second adapter, not a migration.New
public.billing_customers,public.subscriptionsandpublic.billing_eventstables (migration20260724000300_billing), all service-role write only, with users able to read only their own row. New routes underapp/api/billing/:subscription(GET current entitlement),checkout,portal,webhook.lib/billing/entitlements.tsis the single resolver turning a user id into caps — includinggetSpaceLimits(), which resolves throughspaces.owner_id, because the model is owner-pays, not seats (there is no org entity to sell seats against, and collaborators cost us almost nothing).Robustness details worth keeping: the checkout route never accepts a price, only a plan id; the webhook verifies HMAC-SHA512 over the raw body rather than a re-serialized one (Paystack's own Node sample gets this wrong) and dedupes on the signature, since Paystack events carry no event id; entitlement is granted by
charge.success, which fires on renewals too, so a dropped event self-heals; user resolution has three fallbacks so out-of-order delivery can't strand a payment; and the resolver fails to Free rather than to an error, with a 3-day grace pastcurrent_period_endso a late webhook can't downgrade a paying customer.tests/billing.test.ts(18 tests) locks the catalog contract — an unrecognised plan id must degrade to Free, never to a paid tier — and the signature boundary.vitest.config.tsnow aliasesserver-onlyto a stub so server modules are unit-testable at all.Enforcement is not wired yet: the caps are declared and resolvable, but nothing applies them.
docs/PRICING.md§5 lists where each must land — mostly Postgres triggers, since cards/edges/drawings are written client-side under RLS with no server route to guard. -
AI chats are private by default, and shareable read-only. A space's chats used to be space-scoped: every member could read and post into everyone's threads with no locking, so two people prompting on a shared board interleaved into one history.
ai_conversationsnow carriesuser_id+shared; RLS returns your own chats plus any a co-member has explicitly published, andai_messagesinserts are restricted to the thread's author. A shared chat is therefore a read-only window onto someone's thread — there is no such thing as a conflicting prompt. The sidebar gets a lock/share toggle per chat, marks other people's chats with their face and an eye icon, and swaps the composer for a read-only notice with a "New chat" escape hatch.ai_messagesjoins the realtime publication so a shared chat streams in live rather than sitting frozen. Pre-existing chats are handed to the space owner and left shared, so nothing vanishes. -
Real profile avatars. New
profiles.avatar_url, synced fromauth.usersmetadata (Google OAuth supplies one; the trigger coalesces so a provider that stops sending a picture can't wipe it) and backfilled for existing users. Onecomponents/UserAvatar.tsxreplaces the three hand-rolled avatar implementations that had accumulated in the peers dock, share popover and comment thread — with a sharedAvatarStackfor overlapping rows, and the initials-on-hashed-color fallback intact for email-OTP accounts and broken image URLs. Faces now appear on comment pins, comment rows, the mention picker, the presence stack, live cursor labels, the share list and shared AI chats, so people on a shared board are actually distinguishable.
Changed
- Chat-row actions collapsed into one overflow menu. Adding a share toggle had pushed the AI
sidebar row to four hover-revealed icon buttons plus two status glyphs, crowding out the chat title
they were meant to describe — and the share toggle used
--livegreen, which is reserved for presence, not brand chrome. There is now a single⋯trigger opening a shadcnDropdownMenu(Share with board / Make private · Rename · Export as JSON · Export as Markdown · Delete chat), which also replaces the hand-rolled export popover — one menu surface instead of two, with real keyboard navigation and a portal that can't be clipped by the scrolling sidebar. The remaining shared / read-only markers step down to tertiary ink. - Comments redesigned to a cleaner, more familiar layout. Both surfaces now follow the same structure: an avatar gutter, name + timestamp on one line, the body under it, and a quiet action row — instead of the previous cramped stack. The inline thread widens to 320px, indents replies under their root, folds long threads behind See N earlier replies, and gets a proper composer (your avatar, a growing field, a round send button that only lights up when there's something to send). The all-comments Sheet gains a counted title, an Active / Newest sort toggle and Open / Resolved tabs with live counts, replacing the two stacked sections that buried open threads under resolved history. Comment pins now show the author's face rather than an initial, with a reply-count badge.
- Comment tools are grouped like the other toolbar families. The two loose comment buttons are
now one segmented control matching Text and Media: the icon half toggles pin mode, the caret half
opens a flyout holding Comment and All comments. The unresolved badge rides on the main half.
New
ActionGroupincomponents/Toolbar.tsx— the command-shaped sibling ofToolGroup.
Added
- Pricing & distribution plans (docs). Two new planning docs.
docs/PRICING.mdproposes an owner-pays (not seat-based) model — there is no org/team entity in the schema, only per-spacespace_members, so a space's capabilities resolve from its owner's plan. Free / Personal $6 / Studio $14, with BYO AI keys unlimited on every tier (they cost us nothing) and host-key tokens as the metered axis, extending the existinglib/ai/credits.tsmetering. It flags that space/storage/collaborator caps must be enforced by Postgres triggers, since cards, edges and drawings are written client-side under RLS with no server route to guard. (Its payment-provider section originally recommended Paddle; §4 has since been rewritten around Paystack — Stripe and every MoR option need an entity in a supported country.)docs/DISTRIBUTION.mdcovers the desktop app (Tauri v2 over Electron, with a WebGL/WKWebView spike as the gate) and Docker self-host rules, identifying three blockers:NEXT_PUBLIC_*is inlined at build time so a published image can't carry an operator's config,<Analytics/>is mounted unconditionally inapp/layout.tsx, andnext.config.mjslacksoutput: "standalone".