A blue accent, a phone-shaped app, and quieter comments
Changed
Agent mode wears the popover's chat bubbles. The two AI views are one conversation, but your own
turn was a solid bg-primary slab in agent mode and a soft secondary bubble in the compact
popover — so expanding a chat changed the colour of your own words, which reads as landing in a
different thread rather than the same one at a different size. Agent mode now renders the same
shadcn Bubble/BubbleContent primitives the popover does, with the same fill, radius, padding and
type. Not "the same values, copied": the same components, so the next tweak can't land in one view
and miss the other.
Resolved comments leave the canvas, and the pins can be switched off entirely. A resolved
thread is a finished conversation, and leaving its pin up means a board never gets quieter than the
sum of everything ever discussed on it. CommentLayer now draws only unresolved pins — resolved
ones stay one click away in the Comments sheet's Resolved tab, and opening one from there still
pans to it and shows it, because the currently-open thread is exempt from the filter. That
exemption is also what makes resolving from the open card feel right: the pin goes when you close
the card, rather than vanishing under the cursor mid-click. Above that sits a coarser switch —
Hide comments in the pane's right-click menu, which takes every pin off the board. It's a
per-device preference (cs-comments-visible, alongside the theme and accent in lib/prefs) since
the pins are still there for everyone else, it defaults to on, and everything that puts a comment
in front of you turns it back on: picking the comment tool, clicking a row in the sheet, or the
assistant's focus_comment — the alternative is a control that silently pans to an empty patch of
canvas. The row only appears once the board actually has a thread on it.
The canvas right-click menu is nine rows, down from sixteen. Most of the length was one family
of verbs listed out longhand, so Insert (Text, Sticky note, Sticker, Image, Vault), Diagram
(Process, Decision, Frame) and Notion (import, export) each fold into a single row with a flyout.
MenuNode gains a submenu kind and the row renderer was extracted so a submenu item behaves
exactly like a top-level one. The flyout is a DOM child of its row rather than a portal, for two
reasons: the menu's outside-click dismissal is a contains check that a portal would fall outside
of — closing the menu the moment you reached for a submenu item — and keeping them in one subtree
means travelling from row to flyout never leaves the element holding it open. It opens on hover and
on click (touch has no hover), flips to the left flank near the right edge, lifts near the bottom,
and on a phone, where the 214px parent leaves neither flank room in a 375px viewport, slides back
over the parent instead of running off the edge.
Support contact is support@clipspaces.app, not a personal inbox. The two places the app
asks people to email us — the Contact section of /privacy and the billing footnote in
Settings → Plans — pointed at the maintainer's personal Gmail. A support address on the product
domain is the one that can be handed to someone else, filtered, or auto-replied to. The owner's
Gmail stays in the beta_testers seed, where it is that person's actual sign-in address rather
than a support channel.
The brand accent is blue.--accent moves from the warm near-black ink (#37352f) to
#2383e2, dark mode to #5a9ee6. The value isn't new — it was already in the palette as
--link, so --link is now an alias of --accent rather than a second blue a few degrees away.
The ink survives as --text-1; the neutrals stay warm on purpose, because a cool accent over
warm greys keeps the chrome quiet and lets the one saturated colour carry the emphasis. 34 files
read var(--accent), so this repainted every CTA, ring, badge and active tool from one token.
White on #2383e2 is 3.92:1, under the 4.5:1 WCAG AA floor for the 12-13px labels this app
puts on filled buttons, so accent-as-a-fill became its own token: --accent-solid (#1a6dc0,
5.15:1) paired with --accent-fg. --accent keeps the jobs needing only 3:1 — tints, rings,
icons, borders. The ~20 call sites that filled with --accentand set color: "#fff" moved
across; the dots, bars and comment pins carrying no text did not.
The user-selectable accent needed the same fix: applyAccent() set --accent but knew nothing
about --accent-solid, which would have left filled buttons on the default blue while the rest
of the UI turned violet. It now derives the fill by stepping the chosen colour down until white
clears AA — Amber (#e0823d) is 2.2:1 and Green (#2f9e6e) is 3.0:1 as picked, so this is doing
real work, not guarding a hypothetical. The maths is mirrored in the inline boot script in
app/layout.tsx; the two must change together. The redundant "Blue" preset was dropped,
since Default is now that exact hex.
Canvas grid dots are visible.--dot-color goes 0.22 → 0.32 (dark 0.14 → 0.20). At the
old opacity the grid read as a smudge on a laptop and vanished on a phone in daylight, which
costs the canvas the thing that makes it feel like a surface rather than a void.
Ask Clip AI is a round icon button. It's the most persistent chrome on the board, and a word
rendered over the canvas all session is noise once you know what the button does. The paperclip is
the product's own mark, so the shape carries the meaning; data-tip keeps the name a hover away.
On a shared board, a new AI chat is a room by default, and Clip AI in a room speaks only when
it's tagged. Two halves of the same problem. Chats were private by default everywhere, which is
right on your own board and wrong on one with other people in it: the surprising outcome there is a
thread nobody else can see, and the fix was a toggle you had to remember. POST /api/ai/conversations
now counts the space's members and pending invites and defaults shared accordingly — server-side,
because the panel's member list is fetched asynchronously and a chat started in the first moments
after the panel opens would read an empty list and make itself private on a board full of people.
Either count failing lands on the old private default, the safe direction to be wrong in.
That default only works if a room is livable, which meant changing the addressing rule. It used to
be "the assistant answers unless the message was addressed to someone else" — so an unprefixed
line, which is most of what people write to each other, summoned a third voice. Now
decideAddressing returns answer: named: in a shared chat the assistant answers only when
tagged. That also settles replies without a second rule — a reply is just a message, so quoting a
teammate (or the assistant) summons nobody; tag it in the reply if you want it to weigh in. Solo
chats never consult the rule and are completely unchanged. Two consequences handled: the room
composer's placeholder now names the assistant (@Clip AI to ask) instead of the generic "@ to
mention", since the rule is otherwise invisible from the composer; and the starter chips write the
tag into the message when they fire in a room, or a button under "Try asking" would post a question
the assistant is under orders to ignore. GROUP_CHAT_TASK no longer tells the model it may have
been addressed "by default".
Clip AI's answers in a room are threaded onto the question that summoned them. Follows directly
from the rule above: the assistant speaks only when tagged, several people are talking either side
of it, and an untethered paragraph makes everyone scroll back to work out what it is answering.
openAssistantTurn now sets reply_to on the assistant's row to the question's id, so the answer
renders with the same quoted strip — same component, same author line, same click-to-jump — that a
person's reply gets. The assistant is a participant, so it replies the way one does. Only in a room:
in a solo thread the answer is always to the line directly above it, and quoting that line is noise.
Three details behind it. The threaded insert falls back to a flat one if the foreign key can't be
satisfied, because a question deleted in the split second between the two writes must not take the
whole answer down with it over a cosmetic strip — and the response reports what was actually
stored rather than what was intended, so the asker's bubble can't show a strip that vanishes on the
next reload. The asker's own message now carries authorName from the moment it's sent, or the
strip above the answer reads "Someone" until the Realtime echo fills the name in. And the
context_too_small reply returns its answerId like every other answer, so in a room it no longer
arrives twice — once optimistically and once as its own echo.
Added
A mention is coloured while you are still typing it. "@Kvara" lit up the moment you sent it
and was four grey characters before that, so the composer gave you no way to tell a mention that
resolved from one that quietly didn't — a typo'd name looked exactly like a tagged colleague until
it was too late to fix. The draft now carries the same three-weight palette a sent message does:
the accent for a person, --link for the assistant, a solid accent fill when it's you being
named. All theme vars, so a custom accent and a dark flip both come along for free.
A textarea can't colour part of its value, so MentionTextarea lays a mirror div under a
transparent-inked textarea — same box, same typography, same wrapping, scrolled in lockstep. The
textarea keeps every bit of its behaviour (caret, selection, undo, IME, autogrow) and only stops
painting. Three details keep the two texts from drifting apart mid-word: the chips carry colour
only, never weight or size, which would change glyph metrics; their 2px padding is handed straight
back as negative margin, so a chip costs no width; and the mirror is measured from the textarea's
client box, which excludes the scrollbar that appears once a draft outgrows its max height. The
mirror exists only while the draft actually names someone — ::placeholder inherits color in
some browsers, so a permanently transparent ink would take the placeholder with it — and the
textarea's own ::selection is a translucent accent tint, because it paints above the mirror and
the stock opaque highlight would blank out the very words being selected.
Where a mention starts and ends is now decided in exactly one place (splitMentions in
lib/mentions.ts), shared by the composer and the sent bubble, and it brought the (?![\w-])
guard findMentions always had along with it: "@Adam" is no longer painted as a mention of Ada.
Clip AI can draw. A single draw op covers all six pen tools — one op rather than six
near-identical ones, because Stroke is already uniform (points + colour + size) and the only
difference between tools is how many points are meaningful. The validator enforces that: freehand
takes the whole path, line/arrow/rect/ellipse take exactly two points, and malformed
coordinates are dropped rather than coerced — a NaN in a stroke renders as an invisible or
screen-spanning artefact the user then has to hunt down with the eraser, which is worse than the op
simply not happening. AI strokes are indistinguishable from hand-drawn ones downstream: same shape,
same sync path, same eraser, same undo entry.
The prompt entry is deliberately narrow and sits in its own ANNOTATE group rather than with the
create verbs, because grouping it with the card actions invites the model to "draw" a diagram it
should have built from shape cards. It also now sees that strokes exist — count, tool mix and
bounding box, never the coordinates — and is told outright that it cannot read them, since a model
given a count and no content will otherwise happily invent what you sketched.
The phone toolbar is one round launcher. Below 600px the tool row collapses to a 54px button
in the bottom-left; tapping it raises a vertical list of the tools, and picking one closes the list
and leaves that tool's glyph on the button, so the corner always answers "what happens if I draw
right now?". Text, Image, Shapes and Comments expand inline rather than opening a second popover
— stacked popovers on a phone have nowhere to go and no way to dismiss just the top one. Ask Clip
AI mirrors it in the opposite corner at matched size, keeping the middle of the canvas clear and
both controls in the thumb arc.
The motion is deliberate. The list scales from bottom left (the button) rather than its own
centre, so it reads as coming from the thing you pressed. The stagger runs bottom-to-top, so the
cascade travels away from your thumb in the direction the panel is actually growing. Nothing starts
at scale(0). The exit drops the stagger entirely and runs at DUR.exit, because by then the user
has already decided and every extra frame is dead time. And the glyph swap on the button crossfades
through a 3px blur, which stops you seeing two icons briefly stacked and makes it read as one shape
becoming another. All of it collapses under prefers-reduced-motion.
The phone top bar is Share + avatar. Everything else — Agent, Settings, theme, pending invites
— moved into the avatar's popover through a new extras slot on AuthButton, with an unread dot on
the avatar so folding the bell in doesn't hide the one signal that something needs attention. Share
keeps its own button but drops its accent fill here: with two controls left it doesn't need a filled
treatment to be found, and a solid accent button parked permanently over the canvas is the loudest
thing on screen. Invite state moved out of NotificationsBell into lib/use-my-invites.ts so the
bell and the account menu render the same rows. The old ⋮ menu survives only for the case where
AuthButton renders nothing (no Supabase configured), which would otherwise strand Settings and
theme entirely.
Fixed
"Clear the board" left the drawings on it.BoardState was { cards, edges }, so the reducer
had no idea the pen layer existed and clear_all wiped two of the board's three parallel streams.
The confirm text said "every card and connection", which was technically honest and not what
anyone looking at their own board means by clear. Drawings now go with it, on both paths — the
canvas and server-ops, so an external clear_all over MCP doesn't leave strokes behind either.
Undo already snapshots all three streams, so this stays reversible.
A strokes-only board no longer counts as "already empty": that shortcut skips the confirmation, and
skipping it would have destroyed someone's sketch without asking.
Clearing now asks what to do with the comments. They aren't cards, they can be archived rather
than destroyed, and most of the time you want to keep them — so the dialog grew one segmented row:
Keep / Archive / Delete, shown only when the space actually has open threads. Archive is the
default because it's the reversible one — a resolved thread can be reopened, a deleted one is
gone, and the dialog is destructive enough already. Resolved threads aren't counted; they're
archived by definition, and counting them would overstate what you're about to act on.
This needed useConfirm to grow an optional choice, done as an overload rather than by
widening the return type for everyone. Widening would have been the tidier signature and a genuinely
dangerous change: if (await confirm(…)) on an object is always truthy, so every cancel would have
read as a confirm — across ten call sites that are all destructive. tests/confirm-choice.test.tsx
pins both halves: plain callers still get a boolean, and cancelling reports ok: false so a
cancelled wipe can never archive anyone's comments as a side effect.
The Tips & shortcuts popover painted underneath the Ask Clip AI button. It carried
zIndex: 50 against the button's 40, which looked settled and wasn't: the popover is rendered
inside <ReactFlow>, several levels below the app shell, while the button is a top-level sibling.
A z-index only orders an element against the stack it belongs to, so no number on a descendant
down there can reach past a stack it isn't part of. Raising the whole zoom cluster instead didn't
fix it either — and the ancestors that would explain why (<main>, the canvas-area div,
.react-flow) don't establish a stacking context on inspection, so the culprit is something the
static read didn't surface.
Rather than keep hunting for the right ancestor to raise — an audit that has to stay true forever
as wrappers get added — the popover is now portalled to <body>. There it has one ancestor and
one z-index (65: above the button at 40 and the AI chat at 60/62, below the pickers at 80 and
Settings at 90), and nothing between it and the root can capture it again. Same reason Radix
portals its popovers. It positions itself by measuring the trigger on open, and the outside-click
check now tests the panel as well as the trigger, since the two are no longer parent and child.
The first attempt at that portal made the popover vanish altogether: it wrapped createPortal in
AnimatePresence, and a portal is not a motion element — AnimatePresence tracks children by type
and key, finds nothing it can animate, and renders nothing. The nesting is the other way round,
portal outside and AnimatePresence within.
It now lives in components/CanvasHelpPopover.tsx rather than inline in Canvas.tsx, which is
what makes it testable: free of React Flow, it can be rendered in jsdom.
tests/canvas-help-popover.test.tsx opens it and asserts the panel mounts, that it is not a
descendant of its own container, and that it sits above the button's layer. Three regressions in a
row here were invisible to typecheck and to every other test; this one would have caught all of
them. vitest.config.ts now sets esbuild: { jsx: "automatic" } to match Next, without which any
component render in a test dies on "React is not defined".
Dialogs were floating cards on a phone. Every modal used a viewport-relative width
(min(520px, 94vw)) centred on screen, which on a phone is a panel with a sliver of dimmed canvas
around it, reachable only at the top. Below 720px they're bottom sheets: full width, anchored to the
edge the thumb is already near, rounded only where they meet the screen, respecting
env(safe-area-inset-bottom). One shared .cs-sheet / .cs-sheet-overlay pair rather than four
bespoke treatments, adopted by the Notion picker, stock images, bug report and the personality
wizard. The AI panel already had this at 600px and moved to 720 so a narrow tablet doesn't get a
floating chat window next to a sheet.
Settings showed seven unlabelled icons on a phone. The nav collapsed to a horizontal scroll
strip with .settings-nav-label { display: none }, leaving no way to tell Plans from Usage. It's
now a drill-in: the sheet shows the tab list, picking one shows that tab with a back arrow and its
name, and Escape walks back up a level before closing. Opening straight onto a named tab
(Personalize → Clip AI) still lands there rather than on the menu.
The Plans tab was a 1200px column.auto-fit minmax(180px,1fr) correctly collapsed to one card
per row, which is useless for pricing — comparing plans means seeing them together, and a layout
showing one at a time defeats the point. On the sheet it's a scroll-snap row with the next card
peeking. Nesting a horizontal scroller inside the sheet's vertical one is safe; the axes don't
compete for the gesture.
The app shell had one breakpoint, so every width between a phone and a laptop got desktop chrome
whether it fit or not.600px was the only tier in globals.css, and the top bar needs roughly
800px to lay out honestly: ~490px for the right-hand cluster (session timer, Agent toggle,
theme, settings, notifications, Share, avatar) beside a file pill that grows to ~280px. Everything
in between was squeezed rather than adapted. Two things then went wrong at once. The flex algorithm
was handing the overflow to the wrong side — the left pill is flex: 1 1 0, so its scaled shrink
factor is 1 × 0 = 0 and all of the shortfall landed on Share and the avatar, the two controls
with nowhere to go, while the truncatable board name kept its full width. And the four fixed
elements along the bottom edge (tool pill, zoom rail, Ask-AI button, peers dock) each hardcoded
their own offset and guessed where the others were, so once the centred toolbar grew wide enough to
reach the corner the Ask-AI button sat on top of it.
Now: four tiers, each at a measured width rather than a round number — 1024px (the toolbar reaches
the corner chrome), 900px (the right cluster stops fitting), 760px (the Agent label is the next
thing worth giving up), 600px (phones). The bottom edge derives from a single --cs-rail-bottom
token computed from the toolbar's own offset and height, so nothing along that edge hardcodes a
bottom again and the stack stays correct when the pill resizes at a breakpoint; the phone tier
folds env(safe-area-inset-bottom) in so the toolbar clears the iOS home indicator. The right-hand
cluster is flex-shrink: 0 and the space name ellipsises instead, which is the recoverable failure
of the two — the dropdown still shows the name in full, whereas an off-screen Share button is
simply gone. tests/responsive.test.ts reads the real stylesheet and fails if a JS breakpoint
drifts from its CSS twin or if bottom chrome hardcodes an offset.
The bottom toolbar hid tools behind a gesture with no affordance. On a phone the row wants
~435px against ~344px available, and the overflow was handled by making it silently
horizontal-scroll: no scrollbar, no fade, nothing to suggest Vault or Shapes existed at all.
Sticker, Link, Vault and the shape primitives now fold into the Shapes button, which becomes a
single "More tools" overflow flyout below 600px. It reuses the existing FlyoutPanel rather
than introducing a second overflow idiom, and that panel now wraps instead of clipping — the
Shapes picker alone is 8 items (~370px) and was already running off the edge of a 360px phone with
no way to reach the last options. This is the one piece of responsive behaviour that lives in JS
(lib/use-media-query.ts), because moving markup between two parents is the thing a media query
cannot do; the hook uses useSyncExternalStore with a dedicated server snapshot so there is no
hydration mismatch. Everything else stays in CSS.
Agent mode is reachable on a phone, and the onboarding arrows stop pointing at nothing. The
Agent toggle was cs-desktop-only, so below 600px the mode simply vanished; it now degrades in
two steps (label drops at 760px, then moves into the top bar's More menu as a real switch, since it
is a mode and its state has to stay readable). The session timer hides at 900px but stays mounted
rather than moving — it owns a running countdown, and remounting it into a menu on a resize would
reset a live focus session. The empty-state annotations were gated at 600px while the chrome they
point at starts moving at 900px, so on a tablet they annotated bare canvas; they now hide with the
layout they describe, losing nothing, as the centre panel already offers the same three actions as
real buttons.
The comment digest is scheduled from Postgres, because Vercel's Hobby plan wouldn't deploy it.
Hobby caps cron at once per day and rejects anything more frequent at deploy time, so the
sweeper's */15 * * * * failed the build. Dropping to daily was the obvious fix and the wrong one:
QUIET_MS is five minutes precisely so a burst of replies coalesces into one email while a comment
is never held for two full cycles — at a daily cadence every comment waits up to 24 hours, plus
the hour of jitter Vercel explicitly does not promise to avoid. A notification that arrives tomorrow
isn't a notification. The schedule moved to pg_cron, which has no such limit and calls the same
route over pg_net; the digest logic is not reimplemented in SQL, since it needs the mail provider,
the templates and the origin-aware links. Vercel keeps a daily run as a backstop — legal on
Hobby, free, and it means a misconfigured database schedule degrades digests to once-a-day instead
of stopping them silently. Two schedulers on one route is safe: rows are marked sent_at as they go
and the grouping is re-derived each run, so the second sweep finds nothing pending. The shared
secret lives in Supabase Vault, never in a migration; until it's set the job runs and does
nothing but raise warning, because an unconfigured sweeper and a working one otherwise look
identical from the outside.