Backend:
- Fix contact-create 500 (nil custom_fields, doubled slice, bad RETURNING SQL)
- Avatar upload: migration 000033, S3 public-read, PNG/JPG only,
client-resized to 512px + server dimension cap (1024px)
- Pull avatar_url through user + organization repo queries
Frontend:
- Settings restructured into nested routes with a rail layout
(/app/settings/{profile,notifications,security,members,roles,
workspace,billing,danger}); flat Section/Row primitives replace
the per-card rectangles; Save buttons only render when dirty
- Standalone /app/billing and /app/team removed; legacy URLs
redirect to the settings sections; UserNav trimmed accordingly
- CRM rebuilt: Pipelines CRUD + stage editor, Deals kanban with
HTML5 drag/drop, Tasks bucketed by due-date with inline toggle.
Frontend models realigned with backend (Deal.name, CRMTask.status
enum, paginated list shapes)
- Avatars: AvatarUploader component, client-side canvas resize,
wired into Profile + Workspace settings; UserNav + OrgSwitcher
render the uploaded image with initials fallback
- RBAC: lib/permissions.ts mirrors organization_permission.go;
inline role picker in Members; Roles & access section shows the
permission matrix and per-role member counts
- Audit log page at /app/audit, gated to owner+admin via canManage
- Plans aligned with warmbly-web pricing: Starter/Grow/Business/
Enterprise via lib/plans.ts; PlanPill, billing page, sidebar
badges and LockedSurface all read from the same catalogue
- Header PlanPill shows current plan with status-aware coloring;
sidebar locked rows show the required-plan badge instead of a
generic lock icon
Perf:
- QueryClient defaults (staleTime: 30s, refetchOnWindowFocus: false,
retry: 1) — kills 3-5 round-trip storm on every navigation
- useSubscription, usePlans → staleTime: Infinity (only invalidate
on plan-change mutations); useUser/Timezones/Orgs get long stales
with refetchOnMount: false
- vite.config: optimizeDeps for heavy libs + server.warmup for the
most-mounted entry pages
LockedSurface + feature gating:
- New useFeatureAccess() hook: single source of truth for "can this
org do X". Reads subscription + role, returns hasInbox, hasAdvanced,
hasBulkOps, hasTeam, hasWebhooks, isOwner, plus the current plan
name and status. Pages consult this instead of querying the
subscription directly.
- New LockedSurface component: renders the real page contents behind
a frosted overlay at 40% opacity, with a centered upgrade card on
top. Card has the feature name, a blurb, optional bullets, and a
slate-900 "Upgrade to <Plan>" button (or a "ask your owner" line
if the viewer isn't the owner). Users see what they'd unlock
rather than a blank page.
- /app/unibox is now wrapped in LockedSurface. Non-paid orgs get
the lock with bullets for the actual inbox features.
Billing access:
- /app/billing checks access.isOwner before rendering. Non-owners
see an EmptyBlock explaining that billing is owner-scoped. The
UserNav menu also hides the Billing item entirely from non-owners
so the route isn't even discoverable.
Settings — two-pane sheet:
- Left nav rail (200px, hairline divider) with 6 sections: Profile,
Notifications, Security, Members, Workspace (owner-only),
Danger zone. Each row is the same NavRow visual as the main
sidebar — small icon, h-7, slate-200/70 active state.
- Right panel renders the active section, paginated via URL hash
(#profile, #members, …) so deep links work.
- Profile: first/last name + disabled email + Save.
- Notifications: 5 toggle rows using a slate-900 switch.
- Security: sessions / 2FA / change-password rows.
- Members: inline invite form (email + role pill toggle + Invite
button) at the top, members list, pending invitations list,
link out to the full /app/team page.
- Workspace (owner-only): workspace name + default sender domain.
- Danger zone: red-bordered cards for Delete account and Leave
workspace, each with their own destructive button.
Was still using the shadcn DropdownMenu — different border + shadow,
different open animation, different item heights. Stood out against
every other popover in the dashboard now that OrgSwitcher moved over.
Switched to PopoverMenu with side="top" (sidebar lives at the bottom,
menu has to rise) and align="start". Identity block (name + email)
becomes a short header inside the popover with the same hairline +
shadow as the rest. Settings / Billing / Team use PopoverMenuItem
with 12px icons; Log out is the danger variant so it reads as a
destructive action (red text on hover) without needing its own
treatment.
That's the whole dashboard on one popover primitive now — folders,
sort, accounts, schedule, org switcher, user menu — same micro-
animation, same surface.
Three things from the user pass:
1. /select-org workspace rows showed name + id-substring + "Open →".
The id slice was opaque filler. Replaced with role (uppercase
tracked), plan (when present), and a relative "joined Nd ago"
timestamp. The currently-active workspace gets a sky-tinted row
+ "Current" pill + "Resume →" caption so it's obvious where you
are when you opened the manager.
2. /select-org's inline "Create workspace" form replaced with a
single dashed-border "New workspace" button that opens the same
NewWorkspaceDialog the OrgSwitcher uses. The two entry points
now share one component — no more "manage workspaces" leading
to an input that did the same thing the OrgSwitcher dialog did,
just less polished.
First-time empty state (no orgs, no invites) becomes a focused
single-CTA card: small workspace icon + "Create your first
workspace" + a slate-900 button + a hint about invitations
appearing here once sent.
3. UserNav hover background was bg-white/70 — barely visible on
the cream sidebar. Matches the nav rows' bg-slate-200/40 now so
the bottom user row reads as part of the same nav strip
instead of a separate widget.
OrgSwitcher's "New workspace" used to route to /select-org?new=1,
which is the exact same destination as "Manage workspaces" with a
slightly different hint param. From the user's seat they looked
identical.
- New NewWorkspaceDialog component in
components/app/organizations/NewWorkspaceDialog.tsx — slim brae
modal (same chrome as NewCampaignDialog / NewContactDialog).
Name field, slate-900 Create button. On success it activates the
new workspace (switchOrg + setCurrentOrganization) so the rest of
the dashboard sees it immediately, then closes — no navigation,
no full-page select-org screen.
- OrgSwitcher's "New workspace" item now opens this dialog.
"Manage workspaces" still routes to /select-org. The two
actions are now visibly distinct: a popup for create, a page for
manage.
Logo color: the dashboard mark was #8aa1c1 → #4e6285 (light blue-
gray). Read as a washed-out accent rather than a brand. Switched to
slate-900 at rest with a slight slate-700 hover. Anchors the chrome
properly without going full black.
Was the last surface still using the shadcn DropdownMenu — different
animation curve, different border shadow, avatar tile inside each
row, the works. Stuck out against folders / sort / accounts which
all use PopoverMenu now.
Moved to PopoverMenu with the slim items the rest of the dashboard
uses. Trigger is unchanged in shape (monogram + name + chevron) but
the monogram is now slate-900 (matches the rest of the slate-on-
white chrome instead of the leftover sky tile). Active org gets the
slim PopoverMenuItem "selected" treatment — slate-900 weight + sky
dot — not a heavy zinc background.
Item list: no avatar in each row (the menu is short enough that
names alone read fine), no extra padding. New workspace + Manage
workspaces moved into a separator-divided footer of the popover and
actually wired (navigate to /select-org with ?new=1 vs the plain
selector).
User: "inbox is really really bad. So I want all possible ways to
search for an email that we can do... realtime for everything and the
dashboard to show our latency... show how much unread emails." Plus a
follow-up: "I don't like how the emails looks like because they have
that blue gradient, I want dashboard style good one."
Inbox:
- Wired the backend search endpoint (GET /unibox with from/subject/
unseen/since/until/cursor/limit) — was implemented server-side but
the frontend was never calling it. Inbox now actually reflects
server data.
- New UniboxSearchParams model + searchIncoming client + infinite
useUniboxSearch hook that drops null rows defensively.
- ConversationList: SearchInput (subject substring) + quick-filter
strip (All / Unread / Today / This week). Unread count surfaces
in the SectionBar header AND on the Unread chip. Skeleton +
explicit error block with retry; "Load more · N shown" when more
pages are available.
- UniboxFilterSheet (advanced filters in the right-side panel):
free-text query, sender substring, account picker pulled from
the user's connected mailboxes, status toggle (Any/Unread/Read),
since/until date pickers with toggle, newest/oldest sort. Draft
state mirrors parent until Apply.
LivePanel telemetry (sidebar):
- Real WS roundtrip latency. SocketProvider stamps performance.now()
per heartbeat ref; phoenix phx_reply with that ref computes the
delta and publishes via setWsLatencyMs. LivePanel colour-codes
the latency text: <100ms emerald, <300ms amber, ≥300ms red, "—"
when disconnected.
- Unread count row reads from useAppStore.unseenCount.
- Status label: OFFLINE / CONNECTING / LIVE (with pulse) / IDLE,
tied to connectionStatus + active mailbox count.
Transactional emails (no more blue gradient):
- base.go rewritten as dashboard chrome: cream #f5f6f8 background,
white card with hairline #e2e8f0 border, 8px radius, slate-900
text. Logo monogram in slate, no decorative haze, no gradients.
- login_code / registration_code: tiny uppercase eyebrow + 18px
bold heading + neutral body + monospace code pill in a hairline-
bordered box. No serif type.
- reset_password / welcome: same chrome. Slate-900 primary button
replaces the sky-gradient one. Plaintext link below for accessible
fallback.
- Template tests updated against the new markup; all green.
Reliability:
- ErrorBoundary wraps every route. Silent white pages are gone — any
uncaught render error now surfaces inside the panel with name,
message, stack toggle, and back/retry buttons.
- Boundary keys on pathname so navigating away clears the error.
Campaigns blank fix:
- Drop the legacy HeadSelectMenu + Search components from the page
(suspected layout/click-outside collisions inside the slim SectionBar).
- Rewrite using the new dropdown + SearchInput primitives.
- Add StatStrip with clickable filters (All / Active / Paused / Draft).
- Loading shows skeleton rows. Empty splits between "no campaigns at
all" vs "no campaigns matching the current filter".
Dropdown primitive (web/src/components/ui/popover-menu.tsx):
- Brae-density popover menu — slim trigger, hairline border content
surface, h-7 items, mono kbd accents. Built from scratch rather than
via Radix so styles are authoritative and bundle stays small.
- Click-outside + Esc handling, controlled/uncontrolled open state,
side ("bottom"|"top") + align ("start"|"center"|"end") positioning
with viewport-aware clamping.
- SelectButton helper styled as a brae trigger pill.
Field primitive (web/src/components/ui/field.tsx):
- SearchInput + TextInput + Label. 28px tall, hairline border,
sky focus ring. Replaces the half-dozen ad-hoc inputs across pages.
- SearchInput supports Enter onSubmit and inline clear button.
Contacts browser (ContactsTable.tsx, rewritten in place):
- Standalone view: PageTopbar (Import / Export / New) + StatStrip
(All / Subscribed / Unsubscribed / In campaigns, clickable filters)
+ SectionBar (search + sort dropdown + filters).
- Embedded view: skips topbar/strip, drops into SectionBar — used
inside /app/campaigns/[id]/leads.
- Dense table with avatar + name + email-mono, optional company /
phone columns (hidden on smaller widths), subscribed/unsubscribed
pill, campaigns count, created date.
- Bulk selection floats a footer bar with Edit / Delete / Clear.
- Load-more button for infinite scroll (preserves the existing
useInfiniteQuery hook).
- Sort dropdown wired to the existing SearchContacts API params.
Unibox email browser:
- ConversationList: SectionBar header with count, SearchInput, all /
unread tabs with unread count badge, dense rows with avatar +
bold-when-unread sender + subject + preview + relative time.
Unread items get a thin sky rail on the left margin.
- ConversationItem: relative time formatter, name extraction from
"Name <email>" headers.
- ThreadView: 48px topbar (subject + mark-unread/archive/delete
actions) + section bar (n messages / k participants) + a divided
message stream + composer pinned to bottom.
- MessageBubble: no card chrome; just hairlines between messages.
Sender avatar + bold name + mono email + recipient line + mono
timestamp; prose-rendered body.
- ReplyComposer: edge-to-edge textarea with footer bar (Send,
Schedule popover with "in 1h / tomorrow 9 / next Mon 9", Discard,
char counter). ⌘+Enter to send.
Sidebar:
- Drop the generic "+ New Campaign" sky pill. Replaces it with a
LivePanel that reads ambient cold-email telemetry: status dot
(connected/idle/offline), mailbox count, active count, and a
daily sparkline placeholder. Clicks through to /app/analytics.
The sidebar now reflects what the system is doing rather than
nagging with a CTA.
- Nav rows shrink from h-8/13px to h-7/12.5px to match brae density.
- Section labels switch to small tracked-uppercase with a hairline
divider above each section instead of a margin gap.
Page primitive (web/src/components/layout/Page.tsx):
- New vocabulary: Page > PageTopbar (sticky h-12 with eyebrow +
subtitle + actions) + StatStrip (full-width stat row with vertical
rule dividers and Stat cells) + SectionBar (h-9 sub-header) +
PageBody (scrollable area) + Row + EmptyBlock + TopbarAction.
- Pages fill the entire content panel edge-to-edge. No max-w
ceilings, no centered narrow columns, no rounded card chrome,
no Georgia serif. Tracked-uppercase eyebrow at 10px replaces the
28px serif h1.
- Old PageHeader, StatCard, EmptyState, PageSection live on as
thin shims so any page not yet swept keeps compiling.
Pages swept:
- campaigns: list view becomes a hairline-divided row stream with
status dot + mono ID + status pill + relative date. Loading
shows skeleton rows, error and empty share the same EmptyBlock.
- emails: 4-card stat grid → StatStrip; table edge-to-edge with
sticky thead, hover row, uppercase status label.
- analytics: card grid → StatStrip + a 2-column body with
vertical-rule between the chart and the breakdown rail.
- templates, api-keys, billing, settings, team, crm/{tasks,
pipelines, deals}: PageTopbar + EmptyBlock, no more sky pills.
Outer shell:
- AppShell: drop SkyChrome backdrop and the rounded-tl content tuck.
L-shape becomes side-by-side: white sidebar (220px) + white content
column with a hairline #e2e8f0 divider. No decorative background.
- SkyChrome: now a no-op stub. Kept as a named export so the import
path stays stable if we ever bring decoration back.
- AppNav: drop the "New Campaign" sky pill. Sidebar opens straight
into the nav tree — logo + org switcher in a slim 44px header row,
28px nav rows, sentence-case section labels, hairline borders top
and bottom. Active row = bg-slate-100 + slate-900 text. No shadows.
- AppHeader: 40px breadcrumb strip in the content column (was a
full-width 56px row spanning the shell). No logo here — it lives
in the sidebar header.
- OrgSwitcher: slate-900 initial tile instead of sky-600. Sized to
match the slimmer sidebar (28px tall, 12.5px text).
- UserNav: smaller 24px avatar, tighter row height, slate-100 hover.
Page primitive:
- Page padding: px-4 pt-3 pb-10 (was px-8 pt-8 pb-16).
- PageHeader: single 36px toolbar row — title + subtitle on the same
baseline, actions on the right. Drops the 28px serif title block
and the dedicated subtitle paragraph.
- PageSection: 28px header row with title (12.5px) and inline desc.
- StatCard: redesigned as a divided strip cell. Use inside StatRow
for a row of stats with vertical-rule dividers and a single outer
border, replacing the four floating shadow-tile cards.
- EmptyState: text-led, dashed-border block, 40px tall icon.
Page sweep:
- All /app pages: sky-600 buttons → slate-900 (h-7 px-2.5 rounded-md,
text-[12.5px]). Same height as the new toolbar so the page header
reads as one line.
- emails + analytics: switch StatCards into the new StatRow.
- emails: slate avatar tile instead of sky, slate accent on checkboxes.
- campaigns: slate card chrome instead of sky-tinted hover.
- analytics: slate bars instead of sky, smaller paddings, breakdown
list reads as a tight column instead of a card.
- AppNav: remove "Home" row that pointed at /app/emails (same as Accounts),
which is why two rows lit up together. Accounts is the de facto home.
- AppNav: active row goes from white pill (lighter than chrome) to bg-slate-200
pill (clearly darker than chrome) — a gray step down, not the dark pill from
the previous iteration.
- AppHeader: logo gets a blue lean (#8aa1c1 → #4e6285 on hover) so the brand
shows through without coloring the whole chrome.
- SkyChrome: base from #f4f7fb to #f5f6f8, drop the bottom sky vignette.
Clouds stay; the rest is neutral so the only color cue is intentional.
Reverting the AI-design-system flourishes in the chrome:
- Logo: text-slate-400 at rest, text-slate-700 on hover. Lighter
gray that warms slightly when you mouse it. Drops the sky-600.
- Sidebar nav: brought back the white-pill active state from
before (rounded card with hairline ring + 1px shadow). The
sky-50 + 2.5px rail experiment is gone — it read as a design
system showing off, not a workspace.
- Sidebar hover: bg-white/70 (almost invisible) → bg-slate-200/60.
Now hover actually registers — the row goes visibly grayer.
- Badge: sky-600 → slate-900. Same logic, no accent shouting in
the chrome.
Nothing outside components/layout/{AppHeader,AppNav}.tsx changed.
Four targeted upgrades to actually deliver on "have some taste":
1. Logo: the Logo SVG had fill="white" hardcoded. The moment the
shell flipped from dark sky to light, it disappeared. Switched
to fill="currentColor" so the className drives it (now sky-600
in the header, slate-600 elsewhere).
2. PageHeader: titles now render in Georgia serif (already declared
as --font-serif in global.css). 28px / 500 / -0.01em tracking
in slate-950. Subtitle moved to 13.5px with a max-width to keep
it readable. The serif voice is the same one the auth page
uses for "Your emails deserve the inbox." — gives the dashboard
the same character without explicit branding.
3. StatCard: hard border replaced with a ring-1 + two-layer shadow
(a 1px tight + a 24px diffuse). Cards now read as "sitting on"
the page rather than "drawn on" it — Linear-style elevation.
Number bumped 22→28px, weight 600→500, tracking tightened to
-0.02em. Label flipped to a UPPERCASE 12px caption.
4. EmptyState: rounded-2xl + ring instead of dashed border. Title
in serif to match PageHeader. Soft-tinted bg (#fafbfd) so the
block reads as a quiet placeholder rather than a "no data!"
shout. Icon tile gets a faint shadow.
5. Sidebar active rows: traded the white-pill chrome for a sky-50
tint + a 2.5px sky-500 rail flush against the left edge. Reads
as confident but quiet — the active state announces itself with
colour, not weight.
Also: bumped Page padding 6→8 horizontally, 12→16 bottom; widened
PageHeader bottom margin 6→8. Pages breathe.
Scrapped the deep sky gradient. The dashboard is workspace, not
cinema — the user lives there for hours, the chrome shouldn't compete
with the content.
New shell aesthetic:
- Backdrop (SkyChrome): a near-white sky-tinted base (#f4f7fb),
one degree cooler than the white content panel so the panel
reads forward. Three blurred-white cloud blobs in the upper
half at very low opacity — atmosphere you feel rather than see.
A whisper-faint sky-tint vignette at the bottom. No animation.
- Content panel: pure white, only rounded at the inner corner
(top-left). Flush to the bottom and right edges of the viewport
— no margin band of visible chrome there. A single hairline
border on the top + left edges defines the panel without a
heavy shadow.
- Header: dark text on the light backdrop. Logo in sky-600 not
white. Wordmark slate-900. Chevrons slate-300, crumb text
slate-500/900. Connection indicator and ⌘K search drop their
translucent-white pills for slate hovers.
- Sidebar (AppNav): same light backdrop. Active rows go to a
white pill with hairline ring + 1px shadow (the row "lifts"
off the sidebar). Inactive rows slate-600. New Campaign
reverses to a confident sky-600 pill. Section labels
slate-400 uppercase 0.16em tracking, no longer fighting for
attention.
- OrgSwitcher + UserNav: light-theme triggers — slate-200 hover,
sky-600 + slate-900 avatar tiles, slate-900 names.
Also: campaigns page used to render a forever-skeleton when the
query errored. Switched to checking isLoading / isError explicitly
and added a retry button via EmptyState so the failure mode actually
shows up instead of looking like a slow load.
Six changes to the dashboard chrome, all small individually, together
the shell feels intentional instead of "okay first pass".
Sky gradient (SkyChrome):
- Switched to a directional 135deg gradient that's rich in the
upper-left (where the logo sits) and brightens toward the inner
corner near the content panel. Light pulls the eye to the work.
- Warm bloom moved from the upper-left to the inner corner with
cream/butter undertone — reads as late-sun reflecting off the
white panel back into the chrome.
- Added a cool slate wash over the dark zone to give it depth
without changing apparent hue.
- Hairline noise overlay (inline SVG feTurbulence, no asset) so
the gradient doesn't read as plastic at 4K.
Content panel (AppShell):
- Margin from 4px to 14px. Reads as a deliberate sky window-frame
rather than a near-miss border.
- rounded-tl-2xl → rounded-tl-3xl. Bigger radius = architectural,
not accidental.
- Soft 60px outer drop shadow + an inset top-edge highlight, so
the panel feels suspended in the sky chrome rather than glued
flush against it.
Header (AppHeader):
- h-12 → h-14, logo 28→32px, wordmark 15→16px. Brand has presence.
- Logo gets a 1px-y drop shadow so it sits above the gradient.
- Chevrons: w-3.5→w-4, white/30→white/45. Visible without being loud.
- Crumb text 13→13.5px, inactive segments white/55→white/65.
Sidebar (AppNav):
- "New Campaign" reverses contrast — white pill with sky-700 text
instead of a faint translucent button. Reads as a primary CTA,
not a quiet link.
- Nav rows: text 12.5→13px, inactive white/65→white/75, active
state gets a subtle inset highlight. Hover bg pop +33%.
- Section labels weight 500→600, opacity 40→50, tracking widened
from 0.16em to 0.18em. Quiet but legible.
- Badge gets a small shadow so it stands off the white pill.
Added a small set of layout primitives every page can pull from:
<Page> outer container with width=default|wide|full + padding
<PageHeader> title + optional subtitle/eyebrow + right-side actions
<PageSection> labeled child block with optional actions
<StatCard> icon + label + big number tile
<EmptyState> icon + headline + supporting line + optional CTA
Every page that opted in now reads with the same vocabulary — the
title sits at the top-left of the white content panel with consistent
breathing room, primary actions float to the right of the header,
empty states use the same dashed-card pattern.
Pages refactored:
Stubs (10): billing, settings, team, templates, api-keys, all of
crm (deals/pipelines/tasks). Each was an h1 + description + card
with slightly different paddings; now they're 1:1 PageHeader +
EmptyState. Switched the "primary" colour from zinc-900 to sky-600
to match the new chrome.
Real pages (3): emails, campaigns list, analytics.
- emails: stat strip reduced to <StatCard>×4, header moved to
<PageHeader>, table colours pulled from zinc to slate, accent
switched to sky-600.
- campaigns: same treatment; card hover tint sky-200 instead of
zinc-300; status dot colours kept (emerald/amber/slate).
- analytics: stat row + chart card + side panel + warmup empty
state all reflowed onto the same primitives.
Skipped on purpose:
- contacts (just delegates to ContactsTable; the table itself can
take a tonal pass later)
- unibox (custom chat-style split layout; PageHeader doesn't fit)
- campaigns/[id]/* (sub-pages live inside CampaignLayout which has
its own header; cleaner to redesign that layout once than each
sub-page individually)
Replaces the flat zinc sidebar + thin header with one continuous
sky-coloured chrome wrapping a clean white work surface. Reads as
"work happens inside a room with sky outside the window frame."
Layout shape (the L):
┌──────────────────────────────────────────────────────┐
│ [logo] > [org] > [section] [⌘K ●] │ AppHeader
├──────────┬───────────────────────────────────────────┤
│ │ ╭─── content (white, rounded-tl) ──────╮ │
│ AppNav │ │ │ │
│ │ │ │ │
└──────────┴───────────────────────────────────────────┘
Header and sidebar share one sky-gradient backdrop (SkyChrome). The
content panel tucks into the inner corner with rounded-tl-2xl and a
4px margin on right/bottom so a sliver of sky stays visible at every
edge except the seam — the inner corner is the only "merged" edge.
New components in components/layout/:
- SkyChrome: gradient + two soft blurred glows. No animation; the
auth page is where theatrical clouds live, the dashboard is
intentionally quieter.
- AppHeader: one-row breadcrumb spanning the full width. Warmbly
logo lives in the sidebar-width left zone, then an org-picker
button, then the URL-derived section/subpages. Connection
indicator + ⌘K search on the far right.
- AppNav: sidebar list, styled for the dark sky bg. Same section
grammar as before (Email, CRM, Resources) plus a primary
"New Campaign" action up top, settings + user menu pinned at
the bottom. Hover/active states use translucent white pills.
- AppShell: composes the three above, swaps in the keyboard
shortcuts modal + command palette, takes over from AppLayout.
OrgSwitcher and UserNav restyled for the dark chrome (white text,
faint white borders/hover states) and lost their dependency on
@/components/ui/sidebar — there's no SidebarProvider any more, the
new shell doesn't need one.
Removed components/layout/AppSidebar.tsx (the old zinc sidebar).
Existing per-page layouts (e.g. admin's tab bar) keep working — they
render inside the white content panel exactly as before.