so the menu inherits the
+ // outside-click + Escape close behaviour from the same
+ // primitive the sidebar identity dropdown uses (#578). Width
+ // tracks the longest action label between sensible bounds —
+ // `min-w-56` (224px) so short labels don't collapse the
+ // menu, `max-w-md` (28rem / 448px) so a single ridiculously
+ // long label doesn't blow it open. Items are `whitespace-
+ // nowrap truncate` so a longer-than-max label gets `…` with
+ // the full text reachable via the existing `title=` tooltip.
+ setActionsOpen(false)}
+ align="left"
+ panelClassName="min-w-56 max-w-md py-1"
+ trigger={
+
+
) : null
}
trailing={
diff --git a/frontend/packages/settings/README.md b/frontend/packages/settings/README.md
index 808e05a0..e7423a7e 100644
--- a/frontend/packages/settings/README.md
+++ b/frontend/packages/settings/README.md
@@ -1,13 +1,16 @@
# @dar/settings
-The **Settings dialog** and the user-preference state it owns.
+The **identity dropdown panel** (theme + sign out) and the
+user-preference state it owns.
## What lives here
-- `SettingsModal.tsx` — the dialog opened from the sidebar cog. Built on
- the shared `@dar/ui` `Modal` so it matches every other confirm/overlay
- in the SPA. Today it holds the appearance (light / dark) toggle; it is
- the home for future per-user UI preferences.
+- `AccountMenu.tsx` — the dropdown panel rendered inside the sidebar's
+ email-with-caret button (`#578`). Holds the appearance (light / dark)
+ toggle and the Sign out action. The component is the **panel body
+ only**; the consumer wraps it in the shared `@dar/ui` `Popover` so
+ outside-click + Escape close behaviour is inherited from the same
+ primitive the list-page Actions menu uses.
- `theme.ts` — the light/dark preference: read/resolve (saved choice →
system default), persist to the `dar:theme` localStorage key, and
apply by toggling the `.dark` class on ``. `initTheme()` is
@@ -17,12 +20,18 @@ The **Settings dialog** and the user-preference state it owns.
- Model-aware logic or anything that talks to the API. This package is
pure UI preference; it imports only `@dar/ui` (+ React / lucide).
-- The sidebar chrome that *opens* the modal — that's `@dar/sidebar`.
+- The sidebar chrome that *mounts* the panel — that's `@dar/sidebar`.
- The `.dark` utility remap CSS — that stays in the app's `index.css`
(Tailwind layer), since it's global styling, not component logic.
## Pointers
-- Rendered by `@dar/sidebar` (the cog button) and bootstrapped by
+- Rendered by `@dar/sidebar` (the identity dropdown) and bootstrapped by
`apps/web/src/main.tsx` (`initTheme`).
- Data-flow rule: a UI package never imports `@dar/api` (CLAUDE.md §7).
+
+## History
+
+- v1.0.3: replaced the prior `SettingsModal` with `AccountMenu`. The
+ modal-around-two-controls was heavier than warranted; the dropdown
+ is the right primitive for a profile menu.
diff --git a/frontend/packages/settings/src/AccountMenu.tsx b/frontend/packages/settings/src/AccountMenu.tsx
new file mode 100644
index 00000000..852d4b24
--- /dev/null
+++ b/frontend/packages/settings/src/AccountMenu.tsx
@@ -0,0 +1,110 @@
+// AccountMenu (#578) — the panel body for the sidebar's email-with-caret
+// dropdown. Replaces the old "Settings" modal: same controls (theme
+// toggle + Sign out) but rendered as a dropdown panel instead of a
+// modal, since the controls don't warrant a dimmed-overlay modal.
+//
+// This component is the panel CONTENT only — the consumer wraps it in
+// the shared so outside-click + Escape close behaviour is
+// inherited from the same primitive ListPage's Actions menu uses.
+
+import { useState } from 'react';
+import { LogOut, Moon, Sun } from 'lucide-react';
+
+import { resolveTheme, setTheme, type Theme } from '@dar/customization';
+import { purgeLocalCache, useApiClient } from '@dar/data';
+
+export interface AccountMenuProps {
+ /** Hide the panel after a menu action runs (theme stays — only Sign out closes). */
+ onAfterAction?: (() => void) | undefined;
+ /** Email / display name shown as a small grayed caption above the controls. Optional. */
+ identityLabel?: string | undefined;
+ /** Role caption appended after the email (e.g. "superuser"). Optional. */
+ roleLabel?: string | undefined;
+}
+
+export function AccountMenu({ onAfterAction, identityLabel, roleLabel }: AccountMenuProps) {
+ const [theme, setThemeState] = useState(() => resolveTheme());
+ const client = useApiClient();
+ const [loggingOut, setLoggingOut] = useState(false);
+
+ const choose = (next: Theme) => {
+ setTheme(next);
+ setThemeState(next);
+ // Theme is a sticky control — keep the menu open so the operator can
+ // see the change reflect immediately before deciding to close it.
+ };
+
+ const handleLogout = async () => {
+ setLoggingOut(true);
+ try {
+ await client.logout();
+ } catch {
+ // Logout is idempotent server-side; even on a transient network
+ // error we still bounce the user out so the UI can't pretend
+ // they're still signed in.
+ }
+ // Drop every cached server response + per-model UI hint so a logged
+ // out (or next) user can't read the previous session's data out of
+ // localStorage. Then a full reload re-runs the auth gate, routing an
+ // anonymous session to the login screen with clean in-memory state.
+ purgeLocalCache();
+ onAfterAction?.();
+ window.location.reload();
+ };
+
+ const optionClass = (active: boolean): string =>
+ [
+ 'flex flex-1 items-center justify-center gap-1.5 rounded-md border px-2 py-1.5 text-sm font-medium',
+ active
+ ? 'border-blue-600 bg-blue-50 text-blue-700'
+ : 'border-gray-300 text-gray-700 hover:bg-gray-50',
+ ].join(' ');
+
+ return (
+
+ {identityLabel && (
+
+ Signed in as {identityLabel}
+ {roleLabel ? · {roleLabel} : null}
+
- Ends your session and clears cached data on this device.
-
-
-
- );
-}
diff --git a/frontend/packages/settings/src/index.ts b/frontend/packages/settings/src/index.ts
index 73f5f282..af2ce387 100644
--- a/frontend/packages/settings/src/index.ts
+++ b/frontend/packages/settings/src/index.ts
@@ -1,11 +1,11 @@
-// @dar/settings — the Settings dialog + the appearance (theme) state it
-// owns. Isolated so the modal can grow its own per-user preference
-// surface without touching the app shell or the sidebar.
+// @dar/settings — per-user UI preferences (appearance) and session
+// controls (Sign out), exposed as a dropdown panel mounted from the
+// sidebar identity area (#578).
-export { SettingsModal } from './SettingsModal';
+export { AccountMenu, type AccountMenuProps } from './AccountMenu';
// Theme state lives in @dar/customization (the home for all
// localStorage-backed UI customization); re-exported here so the app
-// shell + Settings modal keep importing it from @dar/settings.
+// shell + AccountMenu keep importing it from @dar/settings.
export {
applyTheme,
getStoredTheme,
diff --git a/frontend/packages/sidebar/package.json b/frontend/packages/sidebar/package.json
index 03c2e6d7..c40e1c4e 100644
--- a/frontend/packages/sidebar/package.json
+++ b/frontend/packages/sidebar/package.json
@@ -17,7 +17,8 @@
"dependencies": {
"@dar/customization": "workspace:*",
"@dar/data": "workspace:*",
- "@dar/settings": "workspace:*"
+ "@dar/settings": "workspace:*",
+ "@dar/ui": "workspace:*"
},
"peerDependencies": {
"lucide-react": "^1.16.0",
diff --git a/frontend/packages/sidebar/src/Sidebar.tsx b/frontend/packages/sidebar/src/Sidebar.tsx
index 9012f696..f35a093f 100644
--- a/frontend/packages/sidebar/src/Sidebar.tsx
+++ b/frontend/packages/sidebar/src/Sidebar.tsx
@@ -1,16 +1,17 @@
// @dar/sidebar — the SPA navigation chrome: the brand header, the
-// per-user actions (Settings / Install), the model filter, the
+// per-user identity menu (theme + sign out), the model filter, the
// metadata-driven app/model nav, and the responsive drawer (static
// column at ≥lg, off-canvas drawer below). The app shell composes
// next to its own content region.
import { useEffect, useMemo, useState } from 'react';
-import { ChevronDown, Download, Menu, Settings } from 'lucide-react';
+import { ChevronDown, Download, Menu } from 'lucide-react';
import { Link, NavLink } from 'react-router-dom';
import { NAV_COLLAPSE_KEY, usePersistedSet } from '@dar/customization';
import { useRegistry } from '@dar/data';
-import { SettingsModal } from '@dar/settings';
+import { AccountMenu } from '@dar/settings';
+import { Popover } from '@dar/ui';
// The browser's `beforeinstallprompt` event (Chromium). Captured so we
// can show an explicit "Install" affordance and call `.prompt()` on
@@ -123,8 +124,10 @@ export function Sidebar() {
// so it never eats horizontal space on a phone/tablet. ``drawerOpen``
// only affects the mobile/tablet presentation.
const [drawerOpen, setDrawerOpen] = useState(false);
- // Settings dialog (cog) — appearance / dark-mode toggle (#84).
- const [settingsOpen, setSettingsOpen] = useState(false);
+ // Identity dropdown (#578) — anchored under the email-with-caret button;
+ // holds the theme toggle + Sign out (was a separate Settings modal in
+ // v1.0.2 and earlier).
+ const [accountOpen, setAccountOpen] = useState(false);
// Collapsed app-group sections (#227), persisted per device via
// @dar/customization (the single home for localStorage-backed prefs).
const [collapsed, setCollapsed] = usePersistedSet(NAV_COLLAPSE_KEY);
@@ -212,33 +215,56 @@ export function Sidebar() {
)}
{BRAND_TITLE}
+ {/* Identity dropdown (#578): a single email-with-caret button
+ replaces the v1.0.2 two-line " · " + Settings
+ button. The dropdown is mounted via the shared
+ (outside-click + Escape inherited from the same primitive
+ the list-page Actions menu uses, #575). The optional Install
+ affordance stays as a sibling button — orthogonal concern. */}
{data?.user && (
-