ClipSpacesClipSpaces Docs
Back to app

All releases

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