DialogContent is a CSS grid, and an implicit grid track has a min-content
floor. `break-words` does not reduce min-content size, so a long
unbreakable URL in any child pushed every child past the card's
max-width instead of wrapping. In message history that left the message
box, the delivery error panel and the action button rendered hundreds of
pixels outside the dialog, off the side of the screen.
An explicit minmax(0,1fr) column removes that floor, after which
break-words can do its job. This fixes every dialog in the app, not just
this one: any long URL, API key or gateway ID hit the same track.
Also makes the details dialog the single scroll container, matching
api-keys and the webhook payload modal, instead of capping the message
body at 224px behind a scrollbar nested in a scrollbar. And the list row
preview now wraps, so a URL-heavy message fills both clamped lines
rather than being cut off partway through the first.
Covered by an e2e regression test at 375px and 1280px that asserts the
dialog does not scroll sideways and that the action button stays inside
it. Verified failing before this change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The effect depended on the whole session object. SessionProvider returns a
new object on every refetch, and React compares deps by reference, so an
identical session still ran the cleanup and the body again: the widget was
destroyed and its script re-injected even though nothing about the user
had changed, closing any chat the user had open.
It now depends on the individual strings the effect actually reads, which
compare by value, so it only re-initialises when the metadata genuinely
changes.
Two related leaks fixed at the same time:
- cleanup called destroy() but left the script tag in the document, so
every re-run added another one for the life of the page. It is now
removed.
- a script whose src is already cached can finish loading after cleanup
ran, and initialising then left a widget behind that nothing would
destroy. That late load is now ignored.
Tests cover all four behaviours and fail against the previous version.
Also gives the API guide page focus before the clipboard test: writeText
rejects on an unfocused document, which is where a page sits while other
workers run, and granting clipboard permissions does not cover it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The API guide imported react-syntax-highlighter's default entry, which is
the highlight.js build with every language compiled in. It also applied
Prism themes to it, and the two use different class names, so the samples
were largely uncoloured on top of being heavy. Switch to PrismLight and
register the six languages the guide actually renders.
GoogleOAuthProvider wrapped the whole app but only the login and register
pages use it, so the Google Identity SDK initialised on every dashboard
page. Move it inside LoginWithGoogle, which is where both consumers go
through.
Total client chunks drop from 3.1M to 2.3M.
Also fixes four e2e tests that were relying on page.goto returning a
fully loaded page. Adding loading.tsx introduced a short skeleton state
that did not exist before, and each test measured or interacted during
it. They now wait on real signals instead of timing:
- bulk send waits for the dropzone row cap, which comes from
useSubscription inside that page's own hook. The previous wait on the
devices response was wrong: the dashboard layout requests the same
query key, so it can resolve before the page hydrates.
- the mobile overflow sweep and the API guide replace networkidle, which
needs a 500ms window with zero connections that link prefetching keeps
pushing out of reach.
- the footer/tab-bar check re-scrolls as the page grows, instead of
scrolling once while the skeleton is still short.
- the overflow sweep measures the billing tab rather than
/dashboard/account, a redirect stub with no layout of its own whose
client-side redirect tore down the measurement.
A new test asserts the samples carry Prism token markup in all five
languages, so a revert to the highlight.js entry fails instead of
silently dropping the colours.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The messaging pages now stream behind a loading.tsx boundary, so their
HTML can paint before React hydrates. Tests that filled the To field or
dropped a CSV immediately after page.goto raced that window: the input
was there, the handlers were not, and the interaction was silently lost.
Under parallel workers the race lost often enough to fail runs at random.
Both specs now navigate through a helper that waits for the client-side
devices fetch, which only fires after hydration effects run, so the form
is proven interactive before the test touches it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- prefetch route-tab links so tab clicks reuse a cached payload instead
of paying an uncached server round trip each time
- add loading.tsx boundaries (dashboard root and each section) so clicks
paint within a frame; section-level files keep the header and tabs
mounted while only the content area swaps
- link the sidebar Account item and the checkout page straight to
/dashboard/account/billing. The /dashboard/account redirect stub stays
for old links, but no internal link pays the extra hop anymore. The
stub also dropped query params, which silently ate the plan-change
success toast.
- set QueryClient defaults (staleTime 60s, no focus refetch, retry 1);
mutations already invalidate their keys, so the user's own changes
stay instant. Device messages and webhook deliveries get a 15s
staleTime since they change from outside the tab.
- replace the axios getCachedSession TTL cache with a token seeded from
the server session in Providers and kept in sync by a session bridge.
Requests attach the token synchronously; /api/auth/session is only a
deduped fallback, instead of a refetch every 2 minutes with a
thundering herd on expiry.
- swap the billing card's 16px loading spinner for a card-shaped
skeleton so the tab no longer looks blank while loading
Tests: interceptor seeding/dedupe/signed-out behavior, provider
defaults and token seeding, nav active-state matching, tab prefetch,
billing loading state, plus e2e coverage for direct-to-billing
navigation and an at-most-one-session-call budget guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/checkout/pro and /checkout/scale failed with "Plan cannot be purchased"
for every user since the Next 16 upgrade (ea1b00c).
The checkout page still typed `params` as a plain object and read
`params.planName` synchronously. Next 16 removed the sync compat shim, so
the value was undefined, axios dropped the key, and the API ran
findOne({ name: undefined }). Mongoose 9 returns null for that where
older versions stripped the key, so the guard threw a message about a
misconfigured plan for a request that never named one. Plan data and
Polar product ids were correct throughout.
- unwrap params with React's use() and drop the `as string` cast that
suppressed the type error
- split the conflated guard into PLAN_NAME_REQUIRED, PLAN_NOT_FOUND and
the existing unpurchasable case, each logged server side
- stop retrying a rejected checkout twice: a 400 is not transient
- mark the Polar product id indexes sparse, they have been failing to
build with E11000 on every boot because most plans leave them unset
Checkout now defaults to yearly. A URL that names an interval still
redirects straight through, so the marketing funnel is unchanged; the
in-app CTAs that name none get an interval chooser with yearly
preselected instead of a silent monthly default.
Also redesigns the checkout states, which were the only place in the
dashboard hardcoding gray-100/white and so rendering a light slab in dark
mode, and the plan picker, which painted the accent on every tier.
Tests: a checkout e2e spec asserting the request body carries planName,
which is what would have caught this, plus API cases pinning the three
guard failures apart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/billing/current-subscription answers in two shapes. A subscriber gets the
Subscription document, which always carries a status because the schema
requires one. A user with no subscription gets a synthesised
{ plan, isActive, usage } with no status, no amount and no dates.
The page read that second shape as a subscription with missing fields, so
every free user was told their subscription status was "Unknown" and shown
two "N/A" billing dates. Absence of a status is not an unknown status.
lib/billing.ts interprets the payload instead of guessing at it. A paid plan
arriving without a status is still genuinely unknown and is still reported
that way, so the earlier fix for fabricated "Active" is preserved.
The redesign splits one flat card into plan identity and usage. The old
inner panels were bg-card with shadow-sm sitting on a bg-card parent, so the
nesting was invisible; they are bordered muted panels now. Free accounts
drop the status pill, the always-N/A dates and the portal link they have
nothing to manage with.
CTAs are Buttons rather than hand-rolled Links with background utilities,
and the upgrade target comes from position on the plan ladder, so Scale
subscribers are no longer sold a tier above Scale and bespoke plans are not
pushed onto the self-serve ladder at all. Added a "Compare all plans" link
to textbee.dev/pricing, matching the label the onboarding plan picker
already uses for the same destination.
Also corrected the Pro fixture from 1900 to 999 cents, a price we do not
charge, and gave the portal link noopener noreferrer.
Verified: build clean, 0 lint errors (21 warnings, unchanged), 153 unit
tests (from 121), 78 e2e (from 75). The three free-account guards and the
pricing link guard were each confirmed to fail against the pre-change
component before being trusted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
community-links was the only file in the app still wrapping a Button in
a Link, four times, producing an anchor around a button: invalid markup
and a nested interactive control that assistive tech announces twice.
Everywhere else already uses Button asChild. Two whole Card blocks sat
commented out, with an icon imported solely for that dead code.
community/page.tsx was the only dashboard section that never got the
mobile pass, keeping p-6 with no p-4 step and an unconditional text-3xl.
It was also the only section missing from the 375px overflow guard,
which is presumably how it was missed. Extracting the shared PageHeader,
which the messaging, webhooks and account layouts all repeated by hand,
fixes that outlier by construction.
Both billing limit grids and the promo modal were locked to two columns
at every width, so the meter captions had no room on a phone.
window.open kept a live opener handle back to the app in four places.
Browsers imply noopener for anchor targets but not for window.open.
The share dialog grid moves to 3 columns then 7. Worth stating plainly:
this is not an overflow fix. I expected 7 icons at grid-cols-4 to
overflow at 375px and the extended guard proved they do not. It was
only an awkward 4 + 3 split.
The overflow guard now covers 12 routes instead of 6 and opens a dialog,
since the densest layouts in the app only exist inside modals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Device type declared a `status` field that the schema does not have
and the API never sends, so `device.status === 'online'` was always
false. The badge took its colour from that phantom field and its text
from `enabled`, so an enabled, working device was styled exactly like a
disabled one. Both now come from `enabled`.
Devices, API keys and webhooks each rendered a bare unstyled
"Error: {error.message}" with no way to recover, putting raw transport
strings like "Request failed with status code 500" in front of users.
formatError already handles axios rejections and rate limits and was
used by none of them.
Adds components/shared/error-state.tsx as the counterpart to the
existing EmptyState, routing all three through formatError and offering
a retry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
None of the three password fields declared autoComplete, so password
managers could not tell them apart and would fill the saved password
into the wrong box. They now declare current-password and new-password.
The delete-account dialog, the most destructive action in the app, had
a label whose htmlFor pointed at an id the textarea never had, and an
email confirmation field with no label at all. Both are now wired, and
the controls moved out of DialogDescription, which Radix targets with
aria-describedby: opening the dialog previously read the labels and
placeholders back as one long description string.
Validation errors are now tied to their inputs via aria-describedby and
announced with role=alert instead of appearing silently.
confirmPassword required 4 characters while newPassword required 8, so
a five-character confirmation reported "Passwords must match" rather
than the actual length problem.
Five icon-only buttons had no accessible name: copy API key, copy
device ID, the API key menu, and both promo code copy buttons.
Four of the five nav landmarks were unlabelled, leaving them
indistinguishable in a screen reader's landmark list. Added a
skip-to-content link, since keyboard users otherwise tab the whole
sidebar on every page.
The delete-account label guard was confirmed to fail against the
previous markup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The create dialog is mounted for the whole session, so its
defaultValues, and the uuid inside them, were evaluated exactly once.
A bare form.reset() then restored that same object, so every webhook
created without reloading the page shared one signing secret.
The secret is what proves a delivery genuinely came from us, so sharing
it across endpoints means compromising any one of them compromises the
rest.
Build the defaults fresh on each use, and reset with a new secret both
when the dialog opens and after a successful create.
The e2e guard was confirmed to fail against the previous code, with
both creates submitting an identical secret.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bulk send rebuild shipped several parsing bugs that silently
produced wrong results rather than failing loudly.
Columns were read from the first parsed row instead of the header.
papaparse only assigns keys for values actually present in a row, so a
short first row hid every column after it: the phone column could not
be selected and the file was unusable. Read results.meta.fields.
Row-level parse errors were discarded. Only fatal stream failures were
reported, so a mis-delimited file parsed into one mangled column and
looked fine. Surface them as a warning.
The plan row cap was checked once at drop time and skipped entirely
while the subscription was still loading, so an over-cap file passed on
a limited plan and nothing revalidated it. The cap is now derived, so
it applies whenever the limit resolves.
Rejected files returned silently, leaving a dropped .xlsx with no
feedback. Report the rejection.
The preview index was left pointing into the old recipient list when
the phone column changed, hiding the preview and disabling its own Next
button. It is now clamped and reset.
Also count segments from the rendered message rather than falling back
to the raw template, which counted the placeholder literals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The header quick action opened the API key modal directly, so a first-time
user who clicked "Add device" was handed a generated key with no mention of
installing the app or granting SMS permissions. The Registered Devices card
already had the right flow: explain the steps, then Continue into the key and
QR code.
That flow lived as local state inside device-list, so it could not be reused.
Extracted to add-device-dialog with the same imperative open() handle the
existing GenerateApiKey uses, and wired into both entry points.
"New API key" deliberately still goes straight to key generation. That button
asks for a key, so the device prerequisites would be noise there. The two
actions now do genuinely different things instead of both opening the same
modal under different labels.
E2e covers both entry points reaching the instructions first and Continue
still reaching key generation, plus New API key skipping the instructions.
Verified the guard fails against the old wiring rather than assuming it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Switching tabs shifted the layout. The cause was broader than History being
full width: all four subroutes set their own width, so nothing lined up.
Send max-w-xl 576px
Bulk max-w-3xl 768px
API max-w-4xl 896px
History none full bleed
The header and tab strip were also full width while the view beneath them was
constrained, so on Send the tabs ran roughly 200px past the form.
The column is now defined once in the messaging layout and wraps the header,
the tabs and the children together. The per-page wrappers are gone, so a new
subroute inherits the width instead of inventing one.
Guard: an e2e visits all four tabs, measures the view rendered beneath the tab
strip, and asserts the widths match and that the column is constrained rather
than stretched.
Getting that guard to actually work took three attempts, each a real hole:
- Comparing the column against the viewport passed even with the constraint
removed, because the sidebar and padding already made it narrower.
- Comparing against the parent's border box passed too, since that box
includes the padding the column sits inside. It now subtracts the padding.
- Measuring the layout column could not detect a page re-constraining itself,
because that column is identical on every tab by construction. It now
measures the rendered view instead.
Verified by reintroducing both regressions separately and watching it fail
each one, with the offending tab named in the message.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bottom of the modal was a label/value table, and most of it repeated the
header: Direction restated the To/From already shown with its arrow icon, and
Number restated the number in the title. What remained was two facts wrapped
in table furniture.
Those facts are now inline chips: status, device, and the gateway ID in a
mono chip. The redundant rows are gone.
Copy moved from a single footer button to one button per field, next to the
thing it copies: the number in the header, the message body, and the gateway
ID. A lone "Copy text" button left the target to be inferred, and it could
only ever copy one of the three.
Reused the existing shared CopyButton rather than adding another, which also
brought its copied-state feedback and toast. It had no accessible name
though, so every icon-only copy button was announced identically. It now
names itself after what it copies, which is what lets the new test address
them individually.
Also overrode the dialog primitive's mobile text centring in this header: it
left the title on the left and the timestamp centred beneath it.
E2e reads the clipboard after each button and asserts all three return their
own value.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Day headers in message history could cover message rows. Two distinct causes,
both reproduced before fixing:
- On mobile the header was pinned to top-14, the same sticky band the mobile
search bar already occupies, so it detached and landed on top of rows.
Being bg-muted/70 with a backdrop blur, row text bled through it, which is
the distortion that was reported.
- On desktop it was pinned to top-0, behind the app header, and it covered
rows scrolled beneath it and swallowed their clicks. Playwright surfaced
this second one: a row click failed with "h3 intercepts pointer events".
Day headers are no longer sticky. A page holds 20 messages, so groups are
short and pinning bought little in exchange for those failures.
Footer links are left-aligned on mobile. Centred links in a single column had
no common edge to scan down.
Modal animation is now a quick fade. Dialogs slid in from the left and top
while zooming, which is the movement that read badly; the slide and zoom are
gone and the duration drops from 200ms to 150ms, applied to both the dialog
and alert-dialog primitives so every modal matches. Sheets still slide, since
that is what a drawer should do.
Guards: the overlap check runs at both mobile and desktop widths and also
asserts a row is still clickable after scrolling. Verified it fails against
the old markup rather than assuming a green test means coverage.
Fixture dates now anchor to local midnight instead of "N hours ago". A
2-hour-old message falls on the previous day when the suite runs shortly
after midnight, which made the Today/Yesterday assertions depend on the wall
clock. It failed exactly that way during this session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings the send screen up to the standard of the rebuilt bulk send flow.
Fixes real defects:
- The single-device auto-select never worked. It was computed in
defaultValues from devices?.data?.length === 1, but react-hook-form reads
defaultValues once on mount, while the devices query is still pending, so
the condition was always false. It now preselects once devices resolve.
- Submitting before devices loaded showed "Required" on the device field,
which then auto-filled a moment later, leaving a stale error beside a
populated field. Submit is disabled until devices load.
- Moved off the legacy raw useQuery(['devices']) plus devices.data, the shared
cache key with a different unwrapped shape that caused the devices?.filter
crash previously.
- Every field used a placeholder as its label. Placeholders vanish on focus
and are not reliably announced, so all three now have real labels.
Recipients are chips instead of a growing stack of inputs. They commit on
Enter, comma or blur, so a number typed and left uncommitted is not silently
dropped at send time. Pasting a list adds several at once.
Splitting a pasted list needed care, and an e2e caught it: splitting on
whitespace shredded "+1 (415) 555-0101" into three fragments. Only unambiguous
separators split first; whitespace splitting is a fallback for tokens that
cannot be a single number.
Also adds a segment counter, inline alerts for success and failure instead of
bare text, and a form reset after sending that keeps the device selected.
Phone helpers moved to lib/sms.ts alongside the segment helpers, so the send
page and bulk send share one tested implementation.
One test correction: the desktop footer assertion required links to sit on
exactly one row, but the row layout wraps by design, so it failed whenever
font metrics pushed a link to a second line. It now asserts the property the
design actually guarantees, that links are not one-per-row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The screen opened with a tall filter card, so messages started below the fold,
and each message was a bordered card capped at max-w-sm, fitting three or four
per mobile screen.
List:
- Messages are now rows grouped under day headings (Today, Yesterday, then the
date). Roughly triples what fits on a phone, and scanning by time is how
people actually look for a message.
- Rows are buttons, so the list is keyboard navigable, with relative times
that reveal the exact timestamp on hover.
- Direction now comes from the `type` the API returns. It was inferred from
whether `sender` was present, which is only a proxy; the old check survives
as a fallback for rows written before `type` existed.
- Pagination renders only when there is more than one page.
- A search that matches nothing is a different state from a device with no
messages, and offers a way back.
Filter bar collapsed into one compact row: search, device, refresh, and auto
refresh moved into a dropdown instead of four inline buttons occupying a whole
row for a rarely-changed setting.
Details dialog reordered around why it is opened: the message body first, then
metadata, then errors, then actions. The exact timestamp is text here because
the list tooltip is hover-only and unreachable on touch.
Composer dialog gains real field labels (it used placeholders as labels, which
vanish on focus and are not reliably announced) and the same segment counter
bulk send has.
Two fixes found along the way:
- Reply passed message.device?._id with no fallback. The endpoint does
populate device, but if it were ever absent the composer would open with no
device and could not send. It now falls back to the device being viewed.
- The device-defaulting effect became derived state, so the list no longer
renders once with no device selected before correcting itself.
Segment helpers moved to lib/sms.ts now that the send page, bulk send and the
composer all use them. Fixtures now carry the populated `device` and relative
dates, so the mocked path matches production and exercises day grouping.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wrapped inline links produced a ragged two-line block with small tap targets.
Links now stack in a single column below sm and return to one row above it.
E2e asserts both directions: every link has a distinct y at 375px, and they
share a row at 1280px.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dashboard:
- Removed the recent activity section.
- Corrected the usage card wording. The number was labelled "sent", but
getCurrentSubscription counts SMS documents with no type filter, and the
same model stores both SMSType.SENT and SMSType.RECEIVED. Inbound messages
consume the quota too, so the cards now say "messages" and carry an explicit
note that sends and receipts both count against the limit.
- "This month" is now "Last 30 days". The backend counts from setMonth(-1), a
rolling window, not the calendar month.
Devices:
- Removed the battery and signal indicators. Neither the device schema nor the
Android app reports either value, so they only ever rendered "unknown" and
"-" beside an icon that implied real telemetry. Also dropped the phantom
batteryLevel field from the Device type so nothing is built on it later.
API guide, rebuilt:
- It rendered a Collapsible that defaulted to closed. Once it moved to its own
route the page looked empty until you found the chevron. It is a page now,
so nothing is collapsed.
- Samples are generated from the user's real device id, with a device picker,
so they run after pasting one key. Every sample previously said
YOUR_DEVICE_ID, so nothing on the page was runnable as-is.
- Code blocks follow the theme. The old ones hardcoded a slate-800 background,
so they were the only dark element on a light page.
- Languages went from 3 to 5 (cURL, Node, Python, PHP, Go), picked once and
applied to every endpoint, replacing a hand-rolled inline Python SVG icon.
- Endpoints now cover bulk send and received messages, not just send and two
lookups. Response examples use the real lowercase status values from the SMS
schema instead of invented uppercase ones, and the batch example is no
longer invalid JSON (it had a trailing comma).
- Keys the copy state per block rather than by index arithmetic over
Object.entries order.
The mount guard usually needed for next-themes is not used in CodeBlock:
resolvedTheme is undefined on the server and first client render, so both
produce the light style. Verified with a console sweep across nine pages in
both colour schemes, zero messages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The old screen was one 425-line component with correctness bugs, not just a
weak layout.
Fixed defects:
- Empty recipients were sent. handleSendBulkSMS mapped every parsed row into
the payload, so a blank phone cell produced a message addressed to "".
There was no filtering, no phone validation and no dedupe, so the same
number listed twice was texted twice.
- User-facing copy printed raw bytes: "max 1048576 bytes".
- The row limit could be wrong. maxRows fell back to 50 while the
subscription query was still loading, so an unlimited-plan user could be
told their file was too big. The cap is now only enforced once the real
limit is known.
- Steps were gated with opacity-50 + pointer-events-none, which leaves
controls in the tab order and unannounced. Locked steps now use `inert`.
- Data came from raw useQuery(['devices']) + devices?.data instead of the
typed useDevices(), the same shared-cache-key shape hazard that caused the
devices?.filter crash in round 1.
The new flow: upload (with parse summary and a preview table of the first 5
rows), map (auto-detected phone column plus live valid/invalid/duplicate
counts), compose (clickable variable chips, SMS segment counter, preview that
cycles through real recipients) and review (explicit count, an itemised list
of skipped rows and why, nothing sent until confirmed).
bulk-csv.ts holds the rules that decide who receives a message, as pure
functions with 24 unit tests. Those tests caught a real bug during
development: the short "to" hint substring-matched a column named "total",
which would have silently auto-selected the wrong column. Short hints are now
exact-match only.
Sample CSV at /samples/bulk-sms-sample.csv using 555 reserved-for-fiction US
and Canada numbers, so an unedited upload cannot text a real person, with an
order-confirmation template that demonstrates multi-variable interpolation.
E2e asserts the actual request payload contains exactly the two valid
recipients from a file seeded with a blank, an invalid and a duplicate row,
and that the parsed-CSV table does not reintroduce sideways scroll at 375px.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The home page was four all-time counters that could not answer either
question a user actually opens the dashboard for: how much quota is left, and
did my recent sends work.
Removed fabricated data:
- Every stat card rendered a green TrendingUp arrow whenever a value existed.
Nothing computed a trend. The stats endpoint returns running totals with no
time window, so there was nothing to compare against.
- "Since last year" on the sent and received counts. getStatsForUser sums
device counters with no date filter, so these are all-time totals.
- "Active Devices" with a "Connected now" caption, over a number that counts
every device including disabled ones. Enabled count is now derived from the
device list we already fetch.
- "Active keys" over a count that included revoked keys; now uses the active
API key list.
Added, all from fields the API already returns:
- Usage cards leading the page: today and this month against their limits,
with progress bars, an amber near-limit state past 80% and an upgrade link.
Unlimited plans (-1) show usage with no meter, since there is no ceiling to
measure against.
- Recent activity: the last 5 messages for the connected device. Messages are
only exposed per device, so the card names the device rather than implying
account-wide coverage.
- All-time totals compacted from four large cards into one honest strip.
lib/usage.ts extracts the daily/monthly derivation that subscription-info
already did inline, so the dashboard and billing page cannot disagree about
remaining quota. Unit tested including the -1 sentinel, custom limit
overrides, and an over-100 percentage (possible when a limit is lowered
mid-period).
No backend changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Search previously listed only the 5 sidebar routes plus Log out, so subroutes
like bulk send, message history, webhook deliveries and every account section
were unreachable by search. Typing "csv" or "invoice" or "password" returned
nothing.
New search-registry.ts is the single source of truth: every user-reachable
destination with a description and the words people actually type, rather than
the labels we happened to choose for the nav. cmdk matches those keywords in
addition to the label, so "csv" finds Bulk send and "invoice" finds Billing.
External resources (Android app, quick start, status, contribute) are included
and open in a new tab.
Mobile had no search at all: the trigger and the dialog were one component
living inside the `hidden md:flex` sidebar. Split them. The dialog now mounts
in the dashboard layout with open state lifted, the sidebar keeps its trigger,
and mobile gets a labelled sticky search bar. That matters most on mobile,
where the tab bar is capped at 4 items and search is the only path to Webhooks
and every subroute.
The palette also gains theme actions, since the theme control now lives in the
desktop-only sidebar.
search-registry.test.ts walks the App Router tree on disk and fails if any
page.tsx has no registry entry, naming the offending route. Redirect-only
routes are allowlisted. Verified it actually fails by removing an entry, rather
than assuming a green test means coverage. Plus e2e proving keyword search,
the mobile path to webhooks, and the keyboard shortcut.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Top bar (app-header):
- Removed the Contribute button. It stays reachable from the footer.
- Removed the theme selector; it moves to the sidebar footer.
- Removed the mobile sheet for signed-in users: it duplicated the bottom tab
bar for navigation and the avatar menu for identity. Signed-out visitors
keep it, since auth pages have no tab bar.
The header is now brand on the left, account on the right.
Footer:
- Rebuilt as a slim single bar. A logged-in user is already converted, so the
app does not need the marketing site's multi-column link farm. It borrows
that footer's visual language (muted surface, muted-to-foreground hovers,
green status pill) so the two still read as one product.
- Fixed the "cut by the side nav" bug. The footer was rendered in
(app)/layout.tsx as a sibling of <main>, so it spanned the full viewport
while the dashboard's fixed sidebar painted over its left 240px, and the
fixed mobile tab bar covered its bottom edge. It is now rendered per
section, inside each content column, with the dashboard copy padded clear
of the tab bar.
Theme control:
- Moved to the sidebar footer and restyled from a dropdown into a three-way
Light/Dark/System segmented control, which now has room to show all three
states at once.
Sidebar icon:
- Dashboard uses LayoutDashboard instead of Home, matching the icon the
avatar menu already uses for the same destination.
E2e harness, two real fixes found while verifying:
- The suite ran against `next dev`, which compiles routes on demand, so
parallel workers hitting cold routes timed out at random. Switched to a
production build; the suite went from 1.3m to ~31s.
- The survey modal (a Math.random() coin flip) and the update-app prompt
(whenever a mocked device reports an old version) could open over any page.
A Radix dialog marks the rest of the page aria-hidden while open, so every
getByRole query found nothing and a different test failed each run. Both are
now suppressed deterministically in the session helper.
Full suite: 16/16 on four consecutive runs.
New chrome.spec.ts guards the footer geometry against both the sidebar and
the mobile tab bar, asserts the top bar stays reduced, and asserts the
sidebar theme control actually toggles the dark class.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- account/layout.tsx: section header + route tabs (Billing & plan first,
since the subscription is the most-visited account content), same RouteTabs
interaction grammar as messaging and webhooks; active section survives
refresh with shareable URLs
- /dashboard/account redirects to /billing (SubscriptionInfo + plan CTAs);
/profile, /security and /support are thin pages reusing the existing forms
- Security combines password change with the delete-account flow in a
clearly separated destructive "Danger zone" card
- legacy routes (edit-profile, change-password, delete-account, get-support)
redirect into the merged sections so old links keep working
- account e2e: billing redirect + mocked subscription, security deep-link
refresh survival, legacy redirects
Build, 28 unit tests, and 16 e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Get Started card is the activation funnel; this makes it exceptional:
- animated progress bar + "x of 6" + momentum copy that changes with
progress; per-step time-estimate chips; benefit-led step copy
- focus model: completed steps collapse to compact checked rows (check-pop
animation), only the active step expands with one primary CTA; staggered
entrance; auto-advance when the poll detects a step completed
- register_device is now self-serve inline: numbered instructions beside an
in-place generate-key + QR panel (reuses the existing key mutation and
react-qr-code); help dialog demoted to a "Need help?" fallback
- verify_email shows the actual email and an inline resend with a 60s
cooldown via the existing sendEmailVerificationEmail endpoint
- completion is celebrated (one-time success state with "Send a message"
CTA) instead of the card vanishing mid-glance
- minimize control collapses the card to a slim resumable progress pill
(localStorage), so setup never nags
Fail-closed states (bug fixes):
- previously a backend failure rendered the checklist from missing data,
showing verified users stuck on "Verify your email"; now a status machine
(loading/error/ready/hidden/celebrate) only renders the checklist when all
three queries succeeded; error shows a quiet retry row
- the full-height 6-row loading skeleton is replaced by a compact ~90px
placeholder; background poll failures keep last-good data
Tests: MSW handlers for onboarding PATCH + resend; RTL tests for the error
policy, mid-funnel progress, and all-done progress; e2e asserts the progress
bar on Home. Build, 28 unit tests, and 13 e2e green; verified at 375px.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- personal greeting hero (first name) with a quick-actions row: Send SMS
(primary, -> messaging), Add device and New API key (both open the
generate-key/QR flow; "Add device" keeps the user's mental-model label),
and Quick Start
- webhooks summary row: active-webhook count linking to /dashboard/webhooks,
keeping a discoverable mobile path now that Webhooks left the tab bar and
its section moved off Home
- Devices and API Keys cards show counts in their headers
- mobile-first: actions wrap under the greeting at 375px; verified visually
Build, 25 unit tests, and 13 e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- webhooks/layout.tsx: section header + route tabs (Webhooks | Deliveries),
fixing the page that was previously titled "Webhook Notification Delivery
History" inside a component named MessagingPage
- /dashboard/webhooks now shows subscription management (WebhooksSection,
moved off the dashboard home); /dashboard/webhooks/deliveries shows the
delivery history
- webhooks-section slims down: the layout owns the title and the Deliveries
view, so its header is now just count + Create button; drops the unused
router/icon imports
- webhooks e2e rewritten for the two subroutes including a deliveries
deep-link refresh-survival test
Build, 25 unit tests, and 13 e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- new shared RouteTabs primitive (components/shared/route-tabs.tsx): a
link-based segmented control; tabs are real routes so the active tab
survives refresh and deep links are shareable; aria-current on the active
link and the active pill scrolls into view on mobile
- messaging/layout.tsx renders the section header + tabs; views become
subroutes: / (Send, focused composer), /bulk, /history (now full width,
previously cramped in a half column), /api-guide (the 419-line guide gets
its own URL instead of half the page)
- delete the obsolete client-side tab wrapper (components)/messaging.tsx and
dead main-dashboard.tsx
- messaging e2e rewritten for route tabs, including a direct-load test of
/dashboard/messaging/history as the refresh-survival guarantee
Build, 25 unit tests, and 12 e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root causes of the sideways-scroll blank strip at 375px:
- fixed-width auth cards (w-[400px]/w-[450px]) -> w-full + max-w so they
shrink on small screens
- min-w-[500px] on the community-links dialog -> removed
- webhooks section header: two w-full buttons in a non-wrapping flex row
(403px wide) -> stacks on mobile
- devices/api-keys cards blown past the grid track by min-content sizing
-> min-w-0 max-w-full on the cards
- api-guide SyntaxHighlighter blocks get overflowX auto + maxWidth 100%
- safety net: overflow-x clip on body
Adds e2e/mobile-overflow.spec.ts: at 375px, scrollWidth must not exceed the
viewport on home, messaging, webhooks, account, and login. This guard caught
the webhooks-header and card offenders during the fix.
Build, 25 unit tests, and 11 e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split app/(app)/dashboard/(components)/webhooks-history.tsx into a
webhooks-history/ folder (import path unchanged via index.tsx):
- use-filters.ts: filter state hook (device, webhook, event, status, preset
and custom date ranges) with page reset handled in one place
- filters.tsx: the five filter selects + custom-range popover, deduplicated
via a FilterField wrapper and option tables
- index.tsx: slim container on new useWebhooks / useWebhookNotifications
hooks in lib/api
- promote the numbered pagination (previously duplicated verbatim between
message-history and webhooks-history) to
components/shared/numbered-pagination.tsx
- drop dead code: empty useEffect, never-set isLoading state, ~10 unused
imports; replace the off-palette sky gradient with card tokens
- add a webhooks history e2e guard
Build, 25 unit tests, and 6 e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guards the messaging screen (Send/Bulk/History tabs and the message-history
list against mocked data) before decomposing the 1051-line message-history
component.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- extract the provider tree into app/(app)/providers.tsx and create the
QueryClient once via useState (it was rebuilt on every render, discarding
the cache); remove the old layout-wrapper
- replace the per-navigation whoAmI session check with a global 401 response
interceptor in httpBrowserClient that routes expired sessions to /logout
- rename middleware.ts -> proxy.ts (Next 16 convention), clearing the
deprecation warning; auth guard verified by e2e
- fix .env.example: NEXTAUTH_SECRET (was mislabeled AUTH_SECRET) + NEXTAUTH_URL
- add an e2e assertion for the authenticated login -> dashboard redirect
Build, 19 unit tests, and 3 e2e all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Establish the testing safety net for the web dashboard before refactoring.
- Vitest + React Testing Library + jsdom for unit/component tests
- MSW node server mocks the API; unhandled requests throw so tests can
never reach a real backend
- Playwright e2e boots the real Next app but intercepts every /api/v1 call
with shared fixtures, and mints a NextAuth session cookie to run authed
- Shared fixtures in test/fixtures.ts back both layers
- Seed tests: Button unit tests, MSW sanity checks, and a dashboard e2e
happy path plus an auth-guard redirect
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>