Remove unused portal UI (#6949)

Removes some cluttered/unused UI from the portal:

- Search bar in the header
- The top bar entirely (breadcrumb, notification bell, plan switcher,
user menu)
- The plan/usage indicator in the sidebar footer
- The floating assistant badge

UI only. Where a component isn't deleted it's just no longer rendered,
so anything here is easy to restore.
This commit is contained in:
Reece Browne
2026-07-10 13:16:57 +00:00
committed by GitHub
parent e4379184b5
commit b36f3e0875
10 changed files with 3 additions and 697 deletions
@@ -7182,26 +7182,6 @@ sources = "Sources"
usage = "Usage & Billing"
users = "Users"
[portal.notifications]
markAllRead = "Mark all read"
title = "Notifications"
viewAll = "View all"
[portal.notifications.ariaLabel]
none = "Notifications, no unread"
unread_one = "Notifications, {{count}} unread"
unread_other = "Notifications, {{count}} unread"
[portal.notifications.count]
allRead = "all read"
loading = "loading"
new_one = "{{count}} new"
new_other = "{{count}} new"
[portal.notifications.empty]
description = "No new notifications."
title = "You're all caught up"
[portal.pipelines]
subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources."
title = "Pipelines"
@@ -7882,26 +7862,11 @@ admin = "Admin"
[portal.settings.sections]
account-link = "Account link"
[portal.shell.header]
accountFallback = "Account"
accountMenu = "Account menu"
darkMode = "Dark mode"
lightMode = "Light mode"
search = "Search"
searchPlaceholder = "Search…"
signOut = "Sign out"
switchToDark = "Switch to dark theme"
switchToLight = "Switch to light theme"
[portal.shell.sidebar]
appEditor = "Editor"
appProcessor = "Processor"
brandSuffix = "Stirling Processor"
docsCount = "{{docs}} docs"
linkAccount = "Link Stirling account"
planEditor = "Editor plan"
planEnterprise = "Enterprise plan"
planProcessor = "Processor plan"
primaryNav = "Primary navigation"
switchApp = "Switch app"
@@ -1,19 +1,17 @@
import type { ReactNode } from "react";
import { Sidebar } from "@portal/components/Sidebar";
import { Header } from "@portal/components/Header";
import "@portal/components/AppShell.css";
/**
* Two-column layout: fixed-width sidebar on the left, sticky header + scrolling
* main column on the right. The Sidebar and Header read their state from
* context, so this shell stays prop-free.
* Two-column layout: fixed-width sidebar on the left, a scrolling main column on
* the right. The Sidebar reads its state from context, so this shell stays
* prop-free.
*/
export function AppShell({ children }: { children: ReactNode }) {
return (
<div className="portal-shell">
<Sidebar />
<div className="portal-shell__main">
<Header />
<main className="portal-shell__view">{children}</main>
</div>
</div>
@@ -1,191 +0,0 @@
.portal-header {
height: 3.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 1rem;
background: var(--color-header-bg);
border-bottom: 1px solid var(--color-header-border);
position: sticky;
top: 0;
z-index: 10;
}
.portal-header__left {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 12.5rem;
}
.portal-header__breadcrumb {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-header-text);
}
/* Search trigger */
.portal-header__search {
flex: 0 1 20rem;
margin: 0 auto;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--color-search-bg);
border: 1px solid var(--color-search-border);
border-radius: var(--radius-md);
color: var(--color-search-text);
font-size: 0.75rem;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-header__search:hover {
border-color: var(--color-search-border-hover);
}
/* The icon / placeholder / ⌘K live inside the shared Button's label, so the
label must fill the button for the placeholder's flex:1 to push ⌘K to the
far right (otherwise the label is content-width and everything left-packs). */
.portal-header__search .mantine-Button-inner {
width: 100%;
}
.portal-header__search .mantine-Button-label {
flex: 1;
gap: 0.5rem;
}
.portal-header__search-placeholder {
flex: 1 1 auto;
text-align: left;
}
.portal-header__search-kbd {
font-family: var(--font-mono);
font-size: 0.6875rem;
padding: 0.0625rem 0.25rem;
border-radius: var(--radius-xs);
background: var(--color-bg-muted);
color: var(--color-text-4);
}
/* Right cluster */
.portal-header__right {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 0.375rem;
}
.portal-header__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-md);
color: var(--color-bell-stroke, var(--color-text-3));
position: relative;
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.portal-header__icon-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.portal-header__bell-dot {
position: absolute;
top: 0.375rem;
right: 0.4375rem;
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--color-red);
border: 2px solid var(--color-header-bg);
box-sizing: content-box;
}
/* Tier switcher */
.portal-header__tier {
position: relative;
}
.portal-header__tier-btn {
--sui-btn-py: 0.3125rem;
padding: 0.3125rem 0.625rem;
font-size: 0.75rem;
color: var(--color-text-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-header__tier-btn:hover {
background: var(--color-bg-hover);
border-color: var(--color-border-hover);
}
.portal-header__tier-dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
}
.portal-header__tier-label {
font-weight: 500;
}
.portal-header__tier-menu {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 12rem;
margin: 0;
padding: 0.25rem;
list-style: none;
background: var(--color-dropdown-bg);
border: 1px solid var(--color-dropdown-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 20;
animation: fadeInUp var(--motion-enter) both;
}
.portal-header__tier-option {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4375rem 0.625rem;
font-size: 0.8125rem;
color: var(--color-text-2);
border-radius: var(--radius-sm);
text-align: left;
}
.portal-header__tier-option:hover {
background: var(--color-dropdown-hover);
}
.portal-header__tier-option.is-active {
background: var(--color-nav-active);
color: var(--color-nav-active-text);
}
/* Avatar */
.portal-header__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--grad-blue-btn);
color: #fff;
font-size: 0.8125rem;
font-weight: 600;
}
@@ -1,26 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Header } from "@portal/components/Header";
const meta: Meta<typeof Header> = {
title: "Portal/Shell/Header",
component: Header,
parameters: { layout: "fullscreen" },
decorators: [
(S) => (
<div style={{ background: "var(--color-bg)" }}>
<S />
<div style={{ padding: "1.5rem", color: "var(--color-text-3)" }}>
Page body would render below the header.
</div>
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof Header>;
export const Default: Story = {};
export const FreeTier: Story = { globals: { tier: "free" } };
export const ProTier: Story = { globals: { tier: "pro" } };
export const EnterpriseTier: Story = { globals: { tier: "enterprise" } };
@@ -1,155 +0,0 @@
import { useTranslation } from "react-i18next";
import { ActionIcon, Avatar, Button, Dropdown } from "@app/ui";
import { useAuth } from "@app/auth";
import { useTheme } from "@portal/contexts/ThemeContext";
import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext";
import { useView } from "@portal/contexts/ViewContext";
import { useUI } from "@portal/contexts/UIContext";
import {
SearchIcon,
SunIcon,
MoonIcon,
ChevronDownIcon,
} from "@portal/components/icons";
import { NotificationsDropdown } from "@portal/components/NotificationsDropdown";
import "@portal/components/Header.css";
function ThemeToggle() {
const { theme, toggle } = useTheme();
const { t } = useTranslation();
return (
<ActionIcon
type="button"
variant="quiet"
className="portal-header__icon-btn"
onClick={toggle}
aria-label={
theme === "light"
? t("portal.shell.header.switchToDark")
: t("portal.shell.header.switchToLight")
}
title={
theme === "light"
? t("portal.shell.header.darkMode")
: t("portal.shell.header.lightMode")
}
>
{theme === "light" ? <MoonIcon size={16} /> : <SunIcon size={16} />}
</ActionIcon>
);
}
function TierSwitcher() {
const { tier, setTier, isDerived } = useTier();
const info = TIER_INFO[tier];
// In the app the tier is derived from the real link/wallet state and can't
// be switched by hand; the dropdown only renders where the tier is pinned
// (Storybook / demo surfaces — see TierProvider's initialTier).
if (isDerived) return null;
return (
<Dropdown.Root align="end">
<Dropdown.Trigger>
<Button
type="button"
variant="quiet"
justify="start"
className="portal-header__tier-btn"
leftSection={
<span
className="portal-header__tier-dot"
style={{ background: info.dotColor }}
aria-hidden
/>
}
rightSection={<ChevronDownIcon size={14} />}
>
<span className="portal-header__tier-label">{info.label}</span>
</Button>
</Dropdown.Trigger>
<Dropdown.Menu width="12rem">
{(Object.keys(TIER_INFO) as Tier[]).map((id) => (
<Dropdown.Item
key={id}
active={tier === id}
onSelect={() => setTier(id)}
leading={
<span
className="portal-header__tier-dot"
style={{ background: TIER_INFO[id].dotColor }}
aria-hidden
/>
}
>
{TIER_INFO[id].label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Root>
);
}
function UserMenu() {
const { t } = useTranslation();
const { displayName, signOut } = useAuth();
const name =
displayName ?? t("portal.shell.header.accountFallback", "Account");
return (
<Dropdown.Root align="end">
<Dropdown.Trigger>
<ActionIcon
type="button"
variant="quiet"
className="portal-header__user"
aria-label={t("portal.shell.header.accountMenu", "Account menu")}
title={name}
>
<Avatar name={name} size="md" tone="blue" />
</ActionIcon>
</Dropdown.Trigger>
<Dropdown.Menu width="12rem">
<Dropdown.Item disabled>{name}</Dropdown.Item>
<Dropdown.Item onSelect={() => void signOut()}>
{t("portal.shell.header.signOut", "Sign out")}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Root>
);
}
export function Header() {
const { activeView } = useView();
const { openSearch } = useUI();
const { t } = useTranslation();
return (
<header className="portal-header">
<div className="portal-header__left">
<span className="portal-header__breadcrumb">
{t(`portal.nav.${activeView}`)}
</span>
</div>
<Button
type="button"
variant="quiet"
className="portal-header__search"
aria-label={t("portal.shell.header.search")}
onClick={openSearch}
>
<SearchIcon size={14} />
<span className="portal-header__search-placeholder">
{t("portal.shell.header.searchPlaceholder")}
</span>
<span className="portal-header__search-kbd" aria-hidden>
K
</span>
</Button>
<div className="portal-header__right">
<ThemeToggle />
<NotificationsDropdown />
<TierSwitcher />
<UserMenu />
</div>
</header>
);
}
@@ -1,76 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { http, HttpResponse, delay } from "msw";
import { NotificationsDropdown } from "@portal/components/NotificationsDropdown";
import { NOTIFICATIONS } from "@portal/mocks/notifications";
const meta: Meta<typeof NotificationsDropdown> = {
title: "Portal/Header/NotificationsDropdown",
component: NotificationsDropdown,
parameters: { layout: "centered" },
decorators: [
(S) => (
<div
style={{
padding: "4rem 8rem",
minHeight: "30rem",
background: "var(--color-bg)",
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof NotificationsDropdown>;
export const Default: Story = {};
export const Empty: Story = {
parameters: {
msw: {
handlers: [http.get("/v1/notifications", () => HttpResponse.json([]))],
},
},
};
export const Loading: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/notifications", async () => {
await delay("infinite");
return HttpResponse.json([]);
}),
],
},
},
};
export const HighVolume: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/notifications", () => {
const items = Array.from({ length: 24 }, (_, i) => ({
...NOTIFICATIONS[i % NOTIFICATIONS.length],
id: `n${i + 1}`,
}));
return HttpResponse.json(items);
}),
],
},
},
};
export const NetworkError: Story = {
parameters: {
msw: {
handlers: [
http.get("/v1/notifications", () =>
HttpResponse.json({ error: "Service unavailable" }, { status: 503 }),
),
],
},
},
};
@@ -1,139 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ActionIcon, Button, Dropdown, EmptyState, Skeleton } from "@app/ui";
import { BellIcon } from "@portal/components/icons";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchNotifications,
markAllNotificationsRead,
type Notification,
type NotificationCategory,
} from "@portal/api/notifications";
import "@portal/components/NotificationsDropdown.css";
const CATEGORY_COLOUR: Record<NotificationCategory, string> = {
pipeline: "var(--color-blue)",
deploy: "var(--color-green)",
billing: "var(--color-amber)",
audit: "var(--color-purple)",
agent: "var(--color-cat-insurance)",
doc: "var(--color-cat-healthcare)",
};
export function NotificationsDropdown() {
const { t } = useTranslation();
const state = useAsync<Notification[]>(() => fetchNotifications(), []);
const { data: items } = state;
const { isLoading } = useSectionFlags(state);
// Optimistically clear on "mark all read"; revert if the request fails.
const [cleared, setCleared] = useState(false);
const visible = cleared ? [] : (items ?? []);
async function onMarkAllRead() {
setCleared(true);
try {
await markAllNotificationsRead();
} catch {
setCleared(false);
}
}
const isEmpty = !isLoading && visible.length === 0;
const hasUnread = visible.length > 0;
return (
<Dropdown.Root align="end">
<Dropdown.Trigger>
<ActionIcon
type="button"
variant="quiet"
className="portal-header__icon-btn portal-header__icon-btn--badge"
aria-label={
hasUnread
? t("portal.notifications.ariaLabel.unread", {
count: visible.length,
})
: t("portal.notifications.ariaLabel.none")
}
>
<BellIcon size={16} />
{hasUnread && (
<span className="portal-header__bell-dot" aria-hidden />
)}
</ActionIcon>
</Dropdown.Trigger>
<Dropdown.Menu width="22.5rem" className="portal-notif__menu">
<div className="portal-notif__header">
<span className="portal-notif__title">
{t("portal.notifications.title")}
</span>
{hasUnread ? (
<span className="portal-notif__count">
{t("portal.notifications.count.new", { count: visible.length })}
</span>
) : isLoading ? (
<span className="portal-notif__count portal-notif__count--quiet">
{t("portal.notifications.count.loading")}
</span>
) : (
<span className="portal-notif__count portal-notif__count--quiet">
{t("portal.notifications.count.allRead")}
</span>
)}
</div>
{isLoading && (
<div className="portal-notif__loading">
<Skeleton height="0.875rem" />
<Skeleton height="0.6875rem" width="60%" />
<Skeleton height="0.875rem" />
<Skeleton height="0.6875rem" width="60%" />
</div>
)}
{isEmpty && (
<EmptyState
size="compact"
title={t("portal.notifications.empty.title")}
description={t("portal.notifications.empty.description")}
/>
)}
{!isLoading && !isEmpty && (
<ul className="portal-notif__list">
{visible.map((item) => (
<li key={item.id} className="portal-notif__item">
<span
className="portal-notif__dot"
style={{ background: CATEGORY_COLOUR[item.category] }}
aria-hidden
/>
<div className="portal-notif__body">
<div className="portal-notif__item-title">{item.title}</div>
<div className="portal-notif__desc">{item.description}</div>
<div className="portal-notif__time">{item.time}</div>
</div>
</li>
))}
</ul>
)}
<div className="portal-notif__footer">
<Button
type="button"
variant="quiet"
className="portal-notif__action"
onClick={onMarkAllRead}
disabled={!hasUnread}
>
{t("portal.notifications.markAllRead")}
</Button>
<Button
type="button"
variant="quiet"
className="portal-notif__action"
>
{t("portal.notifications.viewAll")}
</Button>
</div>
</Dropdown.Menu>
</Dropdown.Root>
);
}
@@ -4,7 +4,6 @@ import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
import { useUI } from "@portal/contexts/UIContext";
import { AppShell } from "@portal/components/AppShell";
import { AssistantMount } from "@portal/components/AssistantMount";
import { SearchModal } from "@portal/components/SearchModal";
import { PortalSettingsHost } from "@portal/components/PortalSettingsHost";
import { ViewRouter } from "@portal/ViewRouter";
@@ -66,7 +65,6 @@ export function PortalChrome() {
<RoutedContent />
</AppShell>
</ToolRegistryProvider>
<AssistantMount />
<SearchModal />
<PortalSettingsHost />
</>
@@ -77,37 +77,3 @@
flex-direction: column;
gap: 0.5rem;
}
.portal-sidebar__usage {
padding: 0.5rem 0.625rem;
font-size: 0.75rem;
color: var(--color-usage-text);
}
.portal-sidebar__usage-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.portal-sidebar__usage-value {
color: var(--color-usage-value);
font-weight: 500;
}
.portal-sidebar__plan {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--color-text-2);
font-weight: 500;
}
.portal-sidebar__plan-dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--color-green);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-green) 30%, transparent);
}
@@ -3,12 +3,9 @@ import { AppSwitch } from "@app/components/shared/AppSwitch";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useView, type ViewId } from "@portal/contexts/ViewContext";
import { useTier } from "@portal/contexts/TierContext";
import { useTheme } from "@portal/contexts/ThemeContext";
import { useUI } from "@portal/contexts/UIContext";
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchFleetStats, type FleetStats } from "@portal/api/fleetStats";
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
@@ -21,36 +18,6 @@ import {
} from "@portal/components/sidebarGroups";
import "@portal/components/Sidebar.css";
function UsageFooter() {
const { tier } = useTier();
const { t } = useTranslation();
// Real 30-day processed-PDF count from the fleet-usage endpoint (the same
// source as Home's status strip, so the two can't drift). null → "—".
const { data, loading } = useAsync<FleetStats>(() => fetchFleetStats(), []);
const docs = loading ? undefined : (data?.pdfsProcessed ?? undefined);
const planLabel =
tier === "free"
? t("portal.shell.sidebar.planEditor", "Editor plan")
: tier === "pro"
? t("portal.shell.sidebar.planProcessor", "Processor plan")
: t("portal.shell.sidebar.planEnterprise", "Enterprise plan");
return (
<div className="portal-sidebar__usage">
<div className="portal-sidebar__usage-line">
<span className="portal-sidebar__plan">
<span className="portal-sidebar__plan-dot" aria-hidden />
{planLabel}
</span>
<span className="portal-sidebar__usage-value">
{docs != null ? t("portal.shell.sidebar.docsCount", { docs }) : "—"}
</span>
</div>
</div>
);
}
export function Sidebar() {
const { activeView, setActiveView } = useView();
const { theme } = useTheme();
@@ -135,7 +102,6 @@ export function Sidebar() {
icon={<SettingsIcon />}
onClick={() => openSettings()}
/>
<UsageFooter />
</div>
</aside>
);