@stigmer/react 3.13.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/cursor-accounts/CursorAccountsConsole.d.ts +3 -2
  2. package/cursor-accounts/CursorAccountsConsole.d.ts.map +1 -1
  3. package/cursor-accounts/CursorAccountsConsole.js +3 -2
  4. package/cursor-accounts/CursorAccountsConsole.js.map +1 -1
  5. package/cursor-accounts/MemberCoverageTable.d.ts +11 -3
  6. package/cursor-accounts/MemberCoverageTable.d.ts.map +1 -1
  7. package/cursor-accounts/MemberCoverageTable.js +70 -42
  8. package/cursor-accounts/MemberCoverageTable.js.map +1 -1
  9. package/cursor-accounts/MemberKeysPanel.d.ts.map +1 -1
  10. package/cursor-accounts/MemberKeysPanel.js +4 -1
  11. package/cursor-accounts/MemberKeysPanel.js.map +1 -1
  12. package/internal/ResizableSplit.d.ts +1 -1
  13. package/package.json +4 -4
  14. package/sidebar/SettingsSidebar.d.ts.map +1 -1
  15. package/sidebar/SettingsSidebar.js +3 -3
  16. package/sidebar/SettingsSidebar.js.map +1 -1
  17. package/sidebar/WorkspaceSidebar.d.ts +1 -1
  18. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  19. package/sidebar/WorkspaceSidebar.js +19 -15
  20. package/sidebar/WorkspaceSidebar.js.map +1 -1
  21. package/sidebar/chrome.d.ts +21 -7
  22. package/sidebar/chrome.d.ts.map +1 -1
  23. package/sidebar/chrome.js +25 -7
  24. package/sidebar/chrome.js.map +1 -1
  25. package/src/cursor-accounts/CursorAccountsConsole.tsx +3 -2
  26. package/src/cursor-accounts/MemberCoverageTable.tsx +222 -200
  27. package/src/cursor-accounts/MemberKeysPanel.tsx +25 -1
  28. package/src/cursor-accounts/__tests__/CursorAccountsConsole.test.tsx +64 -8
  29. package/src/cursor-accounts/__tests__/MemberCoverageTable.layout.test.tsx +212 -0
  30. package/src/internal/ResizableSplit.tsx +1 -1
  31. package/src/sidebar/SettingsSidebar.tsx +11 -5
  32. package/src/sidebar/WorkspaceSidebar.tsx +75 -67
  33. package/src/sidebar/__tests__/SettingsSidebar.test.tsx +6 -2
  34. package/src/sidebar/chrome.tsx +29 -15
  35. package/styles.css +1 -1
@@ -21,6 +21,7 @@ import {
21
21
  } from "@stigmer/protos/ai/stigmer/platform/cursoraccount/v1/io_pb";
22
22
  import { StigmerContext } from "../../context";
23
23
  import { FetchCacheContext } from "../../internal/FetchCacheProvider";
24
+ import { openMenu } from "../../__tests__/helpers/open-menu";
24
25
  import { CursorAccountsConsole } from "../CursorAccountsConsole";
25
26
 
26
27
  interface MockCursorAccounts {
@@ -257,13 +258,17 @@ describe("CursorAccountsConsole", () => {
257
258
  await waitFor(() =>
258
259
  expect(screen.getByRole("table", { name: "Team coverage" })).toBeTruthy(),
259
260
  );
260
- // Category 1: on the team, key held — email, key name, spend, status.
261
+ // Category 1: on the team, key held — the key name/label folds into
262
+ // the identity cell (no separate key column: stigmer#929 traded it
263
+ // for member-column width).
261
264
  expect(screen.getByText(/On the team — key held/)).toBeTruthy();
262
265
  expect(screen.getByText("zane@scenar.ai")).toBeTruthy();
263
266
  expect(screen.getByText("stigmer-prod")).toBeTruthy();
264
- // The email is the row's identity: the cell wraps rather than
265
- // truncates, so the full value is always visible text on both row
266
- // kinds and never a native title (banned, stigmer-cloud#268).
267
+ // The email is the row's identity: it truncates with the
268
+ // overflow-gated house tooltip (never break-words, which #929 showed
269
+ // degenerating to one character per line under column pressure) and
270
+ // never a native title (banned, stigmer-cloud#268) — the full value
271
+ // stays in the DOM as selectable text on both row kinds.
267
272
  expect(document.querySelector("[title]")).toBeNull();
268
273
  expect(screen.getByText("$0.17")).toBeTruthy(); // included, 169342 micro-USD
269
274
  expect(screen.getByText("Active")).toBeTruthy();
@@ -282,6 +287,12 @@ describe("CursorAccountsConsole", () => {
282
287
  // Header sync line: active members from server facts, removed seats
283
288
  // by list arithmetic (roster entries minus active members).
284
289
  expect(screen.getByText(/2 members · 1 removed seat/)).toBeTruthy();
290
+ // Only key-backed rows carry an actions kebab; gap rows have none.
291
+ expect(screen.getAllByRole("button", { name: /^Actions for/ })).toHaveLength(1);
292
+ // The heading subtitle answers roster health without scrolling to
293
+ // the gap group.
294
+ expect(screen.getByText(/1 covered/)).toBeTruthy();
295
+ expect(screen.getByText("1 without keys")).toBeTruthy();
285
296
  });
286
297
 
287
298
  it("affirms full coverage instead of hiding the gap group when every active member holds a key", async () => {
@@ -377,14 +388,16 @@ describe("CursorAccountsConsole", () => {
377
388
  expect(screen.getByText("Not on team")).toBeTruthy();
378
389
  expect(screen.getByText("Left team")).toBeTruthy();
379
390
 
380
- // One "Copy invite" per off-team rowa copy, never a navigation.
381
- const copyButtons = screen.getAllByRole("button", { name: "Copy invite" });
382
- expect(copyButtons).toHaveLength(2);
383
- await userEvent.click(copyButtons[0]);
391
+ // ONE "Copy invite" in the group header the link is account-level
392
+ // and identical for every off-team owner, so per-row buttons were
393
+ // redundant (stigmer#929). Always a copy, never a navigation.
394
+ await userEvent.click(screen.getByRole("button", { name: "Copy invite" }));
384
395
  await waitFor(() =>
385
396
  expect(writeText).toHaveBeenCalledWith("https://cursor.com/team-invite/abc"),
386
397
  );
387
398
  expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy();
399
+ // The heading subtitle counts the off-team keys.
400
+ expect(screen.getByText(/2 off-team/)).toBeTruthy();
388
401
  });
389
402
 
390
403
  it("points at the Cursor dashboard when no invite link is configured", async () => {
@@ -448,6 +461,49 @@ describe("CursorAccountsConsole", () => {
448
461
  expect(screen.queryByText(/Key held — not on the team/)).toBeNull();
449
462
  });
450
463
 
464
+ it("drives Enable/Disable and Remove through the row's kebab menu", async () => {
465
+ const setMemberKeyEnabled = vi.fn().mockResolvedValue(scenarAccount());
466
+ const removeMemberKey = vi.fn().mockResolvedValue(scenarAccount());
467
+ const client = createMockStigmer({
468
+ listAccounts: vi.fn().mockResolvedValue({ accounts: [scenarSummary()] }),
469
+ getAccountView: vi.fn().mockResolvedValue(scenarView()),
470
+ setMemberKeyEnabled,
471
+ removeMemberKey,
472
+ });
473
+ render(<CursorAccountsConsole />, { wrapper: wrapper(client) });
474
+
475
+ await waitFor(() => expect(screen.getByText("scenar team")).toBeTruthy());
476
+ await userEvent.click(screen.getByRole("button", { name: /scenar team/ }));
477
+ await waitFor(() =>
478
+ expect(screen.getByRole("table", { name: "Team coverage" })).toBeTruthy(),
479
+ );
480
+
481
+ // The enabled key offers Disable; the menu closes on select.
482
+ await openMenu(
483
+ screen.getByRole("button", { name: "Actions for zane@scenar.ai" }),
484
+ );
485
+ await userEvent.click(screen.getByRole("menuitem", { name: "Disable" }));
486
+ await waitFor(() =>
487
+ expect(setMemberKeyEnabled).toHaveBeenCalledWith({
488
+ accountId: "acc-1",
489
+ keyId: "k-1",
490
+ enabled: false,
491
+ }),
492
+ );
493
+
494
+ // Remove is the kebab's destructive item (live-pin-guarded server-side).
495
+ await openMenu(
496
+ screen.getByRole("button", { name: "Actions for zane@scenar.ai" }),
497
+ );
498
+ await userEvent.click(screen.getByRole("menuitem", { name: "Remove" }));
499
+ await waitFor(() =>
500
+ expect(removeMemberKey).toHaveBeenCalledWith({
501
+ accountId: "acc-1",
502
+ keyId: "k-1",
503
+ }),
504
+ );
505
+ });
506
+
451
507
  it("adds a member key and refreshes both views", async () => {
452
508
  const addMemberKey = vi.fn().mockResolvedValue(scenarAccount());
453
509
  const client = createMockStigmer({
@@ -0,0 +1,212 @@
1
+ // Layout-contract regression suite for the coverage table's column
2
+ // budget (stigmer#929). Runs in a real Chromium via
3
+ // `vitest.a11y.config.ts` — the defect this pins was a grid-track
4
+ // collapse (fixed tracks starving the flexible member column until
5
+ // emails rendered one character per line), which only a real layout
6
+ // engine can resolve.
7
+ //
8
+ // The contract has two halves:
9
+ //
10
+ // 1. At the settings-page canvas (both client apps render settings in
11
+ // `max-w-3xl`, ~720px of content), the table fits WITHOUT horizontal
12
+ // scroll and the member column keeps enough width to render typical
13
+ // emails whole.
14
+ // 2. In a narrower host (the SDK component is embeddable at any width,
15
+ // DD-004), the grid's min-width guard turns crushing into horizontal
16
+ // scrolling — the member column never collapses.
17
+ //
18
+ // Like the provider layout suite (DD-019), this renders against the
19
+ // SHIPPED stylesheet (`dist/styles.css`, built by `npm run build:libs`),
20
+ // so the contract is verified on the artifact consumers actually load.
21
+
22
+ import "../../../dist/styles.css";
23
+
24
+ import { describe, it, expect, afterEach } from "vitest";
25
+ import { render, cleanup, screen } from "@testing-library/react";
26
+ import { create } from "@bufbuild/protobuf";
27
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
28
+ import type { Stigmer } from "@stigmer/sdk";
29
+ import {
30
+ CursorAccountSchema,
31
+ CursorAccountSyncSnapshotSchema,
32
+ CursorMemberKeySchema,
33
+ CursorMemberSpendSchema,
34
+ CursorTeamMemberSchema,
35
+ } from "@stigmer/protos/ai/stigmer/platform/cursoraccount/v1/cursor_account_pb";
36
+ import {
37
+ CursorAccountViewSchema,
38
+ CursorMemberKeyViewSchema,
39
+ CursorMemberKeyState,
40
+ CursorTeamMemberViewSchema,
41
+ } from "@stigmer/protos/ai/stigmer/platform/cursoraccount/v1/io_pb";
42
+ import { StigmerProvider } from "../../provider";
43
+ import { deriveCoverage } from "../cursor-account-coverage";
44
+ import type { useCursorMemberKeyActions } from "../useCursorMemberKeyActions";
45
+ import { MemberCoverageTable } from "../MemberCoverageTable";
46
+
47
+ afterEach(cleanup);
48
+
49
+ // A minimal client: the provider eagerly fetches registries on mount;
50
+ // a null credential keeps that non-blocking and off the network.
51
+ function makeClient(): Stigmer {
52
+ return {
53
+ baseUrl: "https://example.test",
54
+ getAuthCredential: async () => null,
55
+ fetch: (async () => {
56
+ throw new Error("network disabled in test");
57
+ }) as unknown as typeof globalThis.fetch,
58
+ } as unknown as Stigmer;
59
+ }
60
+
61
+ // The table renders actions through this hook's return; layout never
62
+ // invokes them, so rejecting stubs are honest.
63
+ const stubActions: ReturnType<typeof useCursorMemberKeyActions> = {
64
+ addKey: () => Promise.reject(new Error("unused in layout test")),
65
+ removeKey: () => Promise.reject(new Error("unused in layout test")),
66
+ setKeyEnabled: () => Promise.reject(new Error("unused in layout test")),
67
+ isSubmitting: false,
68
+ error: null,
69
+ clearError: () => {},
70
+ };
71
+
72
+ /**
73
+ * A view shaped like the account that exposed #929: all three coverage
74
+ * categories populated, real-length emails, a key with both a Cursor
75
+ * key name and an operator label (the widest identity cell).
76
+ */
77
+ function fixtureView() {
78
+ const spend = (email: string) =>
79
+ create(CursorMemberSpendSchema, {
80
+ email,
81
+ autoPercentUsed: 12.4,
82
+ apiPercentUsed: 100,
83
+ includedSpendUsdMicros: 201550000n,
84
+ overageSpendUsdMicros: 0n,
85
+ });
86
+
87
+ return create(CursorAccountViewSchema, {
88
+ account: create(CursorAccountSchema, {
89
+ accountId: "acc-layout",
90
+ displayName: "layout team",
91
+ adminApiKey: "***REDACTED***",
92
+ enabled: true,
93
+ teamInviteLink: "https://cursor.com/team-invite/layout",
94
+ }),
95
+ snapshot: create(CursorAccountSyncSnapshotSchema, {
96
+ accountId: "acc-layout",
97
+ syncedAt: timestampFromDate(new Date("2026-08-28T10:00:00Z")),
98
+ members: [
99
+ create(CursorTeamMemberSchema, {
100
+ userId: "u1",
101
+ email: "timothy@leftbin.com",
102
+ name: "Timothy M",
103
+ role: "owner",
104
+ }),
105
+ create(CursorTeamMemberSchema, {
106
+ userId: "u2",
107
+ email: "amelia@leftbin.com",
108
+ name: "Amelia F",
109
+ role: "member",
110
+ }),
111
+ ],
112
+ }),
113
+ keyViews: [
114
+ create(CursorMemberKeyViewSchema, {
115
+ key: create(CursorMemberKeySchema, {
116
+ keyId: "k-1",
117
+ apiKey: "***REDACTED***",
118
+ boundEmail: "timothy@leftbin.com",
119
+ cursorKeyName: "stigmer-production-key",
120
+ label: "timothy — prod",
121
+ enabled: true,
122
+ }),
123
+ state: CursorMemberKeyState.member_key_active,
124
+ spend: spend("timothy@leftbin.com"),
125
+ }),
126
+ create(CursorMemberKeyViewSchema, {
127
+ key: create(CursorMemberKeySchema, {
128
+ keyId: "k-2",
129
+ apiKey: "***REDACTED***",
130
+ boundEmail: "lucasb@oneighty.com",
131
+ cursorKeyName: "personal-key",
132
+ enabled: true,
133
+ }),
134
+ state: CursorMemberKeyState.member_key_owner_unknown,
135
+ spend: spend("lucasb@oneighty.com"),
136
+ }),
137
+ ],
138
+ membersWithoutKeysViews: [
139
+ create(CursorTeamMemberViewSchema, {
140
+ member: create(CursorTeamMemberSchema, {
141
+ userId: "u2",
142
+ email: "amelia@leftbin.com",
143
+ name: "Amelia F",
144
+ role: "member",
145
+ }),
146
+ }),
147
+ ],
148
+ });
149
+ }
150
+
151
+ function renderTableAt(hostWidth: number) {
152
+ const view = fixtureView();
153
+ render(
154
+ <div style={{ width: hostWidth }}>
155
+ <StigmerProvider client={makeClient()}>
156
+ <MemberCoverageTable
157
+ accountId="acc-layout"
158
+ coverage={deriveCoverage(view)}
159
+ inviteLink={view.account?.teamInviteLink ?? ""}
160
+ actions={stubActions}
161
+ onChanged={() => {}}
162
+ />
163
+ </StigmerProvider>
164
+ </div>,
165
+ );
166
+
167
+ const table = screen.getByRole("table", { name: "Team coverage" });
168
+ // The overflow guard is the table's direct wrapper (the card-chrome div).
169
+ const scroller = table.parentElement as HTMLElement;
170
+ return { table, scroller };
171
+ }
172
+
173
+ function emailCell(email: string): HTMLElement {
174
+ const cell = screen.getByText(email).closest('[role="cell"]');
175
+ expect(cell, `member cell for ${email}`).not.toBeNull();
176
+ return cell as HTMLElement;
177
+ }
178
+
179
+ describe("MemberCoverageTable column budget (#929)", () => {
180
+ it("fits the 768px settings canvas without horizontal scroll, with a legible member column", () => {
181
+ // max-w-3xl (768px) minus the settings page's px-6 → ~720px content.
182
+ const { scroller } = renderTableAt(720);
183
+
184
+ expect(scroller.scrollWidth).toBe(scroller.clientWidth);
185
+
186
+ // The member column must hold real emails whole. The #929 failure
187
+ // rendered this cell ~1ch wide; anything under ~180px would push
188
+ // typical emails back into truncation as the norm rather than the
189
+ // exception.
190
+ const cell = emailCell("lucasb@oneighty.com");
191
+ expect(cell.getBoundingClientRect().width).toBeGreaterThan(180);
192
+
193
+ // One line: the email never wraps (truncation instead of the
194
+ // character-per-line wrapping that motivated this suite).
195
+ const emailEl = screen.getByText("lucasb@oneighty.com");
196
+ const lineHeight = parseFloat(getComputedStyle(emailEl).lineHeight);
197
+ expect(emailEl.getBoundingClientRect().height).toBeLessThan(lineHeight * 1.5);
198
+ });
199
+
200
+ it("scrolls horizontally in a narrow host instead of collapsing the member column", () => {
201
+ const { table, scroller } = renderTableAt(400);
202
+
203
+ // The min-width guard keeps the grid at its legible minimum and
204
+ // hands the difference to the scroll container.
205
+ expect(scroller.scrollWidth).toBeGreaterThan(scroller.clientWidth);
206
+ expect(table.getBoundingClientRect().width).toBeGreaterThanOrEqual(640);
207
+
208
+ // Even mid-scroll the member cell keeps usable width.
209
+ const cell = emailCell("lucasb@oneighty.com");
210
+ expect(cell.getBoundingClientRect().width).toBeGreaterThan(100);
211
+ });
212
+ });
@@ -66,7 +66,7 @@ export interface ResizableSplitProps {
66
66
  * query. 48rem clears the session layout's content minimums (320px min
67
67
  * chat + drag handle + a usable workspace panel) and approximately
68
68
  * preserves the console's previous viewport-`lg` trigger point, where the
69
- * viewer's box is the ~1024px window minus the ~280px sidebar. It is
69
+ * viewer's box is the ~1024px window minus the ~240px sidebar. It is
70
70
  * deliberately not a prop: container-query conditions cannot read CSS
71
71
  * custom properties, so a runtime threshold would need an inline-`<style>`
72
72
  * or ResizeObserver mechanism — heavier than the static utility this is,
@@ -4,7 +4,12 @@ import type { ReactNode } from "react";
4
4
  import { ArrowLeft } from "lucide-react";
5
5
  import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
6
6
  import type { SettingsNavGroup } from "../settings/settings-nav.js";
7
- import { SidebarChrome, SidebarSeparator, navRowClassName } from "./chrome.js";
7
+ import {
8
+ SidebarChrome,
9
+ SidebarSeparator,
10
+ navRowClassName,
11
+ sectionLabelClassName,
12
+ } from "./chrome.js";
8
13
  import type { RenderSidebarLink } from "./types.js";
9
14
 
10
15
  // ---------------------------------------------------------------------------
@@ -86,13 +91,14 @@ export function SettingsSidebar({
86
91
  onOrgChanged={onOrgChanged}
87
92
  footer={footer}
88
93
  >
89
- {/* Back to Sessions */}
94
+ {/* Back to Sessions — an exit, not a destination; the resting-row
95
+ treatment (muted, brightening on hover) already reads as such. */}
90
96
  <div className="stg:flex-none stg:px-3 stg:py-1">
91
97
  {renderLink({
92
98
  id: "back-to-sessions",
93
99
  href: backHref,
94
100
  active: false,
95
- className: navRowClassName(false, { muted: true }),
101
+ className: navRowClassName(false),
96
102
  "aria-current": undefined,
97
103
  children: (
98
104
  <>
@@ -106,10 +112,10 @@ export function SettingsSidebar({
106
112
  <SidebarSeparator />
107
113
 
108
114
  {/* Settings nav links — grouped, permission-aware via `groups` */}
109
- <div className="stg:flex stg:flex-col stg:gap-4 stg:px-3 stg:py-1">
115
+ <div className="stg:flex stg:flex-col stg:gap-5 stg:px-3 stg:py-1">
110
116
  {groups.map((group) => (
111
117
  <div key={group.label} className="stg:flex stg:flex-col stg:gap-0.5">
112
- <span className="stg:text-sidebar-muted-foreground stg:px-2 stg:pb-1 stg:text-[11px] stg:font-medium stg:tracking-wider stg:uppercase">
118
+ <span className={`${sectionLabelClassName()} stg:pb-1`}>
113
119
  {group.label}
114
120
  </span>
115
121
  {group.items.map((item) => {
@@ -23,7 +23,12 @@ import {
23
23
  TooltipTrigger,
24
24
  TooltipContent,
25
25
  } from "../internal/tooltip.js";
26
- import { SidebarChrome, SidebarSeparator, navRowClassName } from "./chrome.js";
26
+ import {
27
+ SidebarChrome,
28
+ SidebarSeparator,
29
+ navRowClassName,
30
+ sectionLabelClassName,
31
+ } from "./chrome.js";
27
32
  import type { RenderSidebarLink } from "./types.js";
28
33
 
29
34
  // ---------------------------------------------------------------------------
@@ -110,7 +115,7 @@ export interface WorkspaceSidebarProps {
110
115
  * from the product because it *is* the product (stigmer/stigmer#317).
111
116
  *
112
117
  * Layout note: the sidebar fills its container's height and defines no
113
- * width — the console's app shell owns the `w-70` (280px) column.
118
+ * width — the console's app shell owns the `w-60` (240px) column.
114
119
  *
115
120
  * @example
116
121
  * ```tsx
@@ -153,55 +158,57 @@ export function WorkspaceSidebar({
153
158
  onOrgChanged={onOrgChanged}
154
159
  footer={footer}
155
160
  >
156
- <PrimaryNavRow
157
- id="new-session"
158
- href="/"
159
- label="New Session"
160
- icon={Plus}
161
- active={activeNav === "new-session"}
162
- renderLink={renderLink}
163
- />
164
- <PrimaryNavRow
165
- id="dashboard"
166
- href="/dashboard"
167
- label="Dashboard"
168
- icon={LayoutDashboard}
169
- active={activeNav === "dashboard"}
170
- renderLink={renderLink}
171
- />
172
- {/* MessagesSquare, deliberately not MessageSquare — the singular
173
- mark is the session glyph in Recents, and the two areas must
174
- not read as one. */}
175
- <PrimaryNavRow
176
- id="conversations"
177
- href="/conversations"
178
- label="Conversations"
179
- icon={MessagesSquare}
180
- active={activeNav === "conversations"}
181
- renderLink={renderLink}
182
- accessory={
183
- conversationsBadgeCount !== undefined && conversationsBadgeCount > 0 ? (
184
- <WantsHumanBadge count={conversationsBadgeCount} />
185
- ) : null
186
- }
187
- />
188
- <PrimaryNavRow
189
- id="library"
190
- href="/library"
191
- label="Library"
192
- icon={Library}
193
- active={activeNav === "library"}
194
- renderLink={renderLink}
195
- />
161
+ {/* Primary nav — one group container with the same row rhythm as
162
+ the settings groups (gap-0.5), so the two zones share one beat. */}
163
+ <div className="stg:flex stg:flex-none stg:flex-col stg:gap-0.5 stg:px-3 stg:py-1">
164
+ <PrimaryNavRow
165
+ id="new-session"
166
+ href="/"
167
+ label="New Session"
168
+ icon={Plus}
169
+ active={activeNav === "new-session"}
170
+ renderLink={renderLink}
171
+ />
172
+ <PrimaryNavRow
173
+ id="dashboard"
174
+ href="/dashboard"
175
+ label="Dashboard"
176
+ icon={LayoutDashboard}
177
+ active={activeNav === "dashboard"}
178
+ renderLink={renderLink}
179
+ />
180
+ {/* MessagesSquare, deliberately not MessageSquare — the singular
181
+ mark is the session glyph in Recents, and the two areas must
182
+ not read as one. */}
183
+ <PrimaryNavRow
184
+ id="conversations"
185
+ href="/conversations"
186
+ label="Conversations"
187
+ icon={MessagesSquare}
188
+ active={activeNav === "conversations"}
189
+ renderLink={renderLink}
190
+ accessory={
191
+ conversationsBadgeCount !== undefined && conversationsBadgeCount > 0 ? (
192
+ <WantsHumanBadge count={conversationsBadgeCount} />
193
+ ) : null
194
+ }
195
+ />
196
+ <PrimaryNavRow
197
+ id="library"
198
+ href="/library"
199
+ label="Library"
200
+ icon={Library}
201
+ active={activeNav === "library"}
202
+ renderLink={renderLink}
203
+ />
204
+ </div>
196
205
 
197
206
  <SidebarSeparator />
198
207
 
199
208
  {/* Scrollable recents */}
200
209
  <ScrollArea className="stg:flex-1">
201
210
  <div className="stg:p-3">
202
- <p className="stg:text-sidebar-muted-foreground stg:mb-2 stg:px-1 stg:text-[11px] stg:font-semibold stg:tracking-wider stg:uppercase">
203
- Recents
204
- </p>
211
+ <p className={`${sectionLabelClassName()} stg:mb-2`}>Recents</p>
205
212
  {isLoading ? (
206
213
  <RecentsSkeletons />
207
214
  ) : error ? (
@@ -246,24 +253,20 @@ function PrimaryNavRow({
246
253
  /** Trailing row content (e.g. the Conversations count pill). */
247
254
  readonly accessory?: ReactNode;
248
255
  }) {
249
- return (
250
- <div className="stg:flex-none stg:px-3 stg:py-1">
251
- {renderLink({
252
- id,
253
- href,
254
- active,
255
- className: navRowClassName(active),
256
- "aria-current": active ? "page" : undefined,
257
- children: (
258
- <>
259
- <Icon className="stg:size-4 stg:shrink-0" />
260
- {label}
261
- {accessory}
262
- </>
263
- ),
264
- })}
265
- </div>
266
- );
256
+ return renderLink({
257
+ id,
258
+ href,
259
+ active,
260
+ className: navRowClassName(active),
261
+ "aria-current": active ? "page" : undefined,
262
+ children: (
263
+ <>
264
+ <Icon className="stg:size-4 stg:shrink-0" />
265
+ {label}
266
+ {accessory}
267
+ </>
268
+ ),
269
+ });
267
270
  }
268
271
 
269
272
  /**
@@ -311,7 +314,8 @@ function ActivityGroupList({
311
314
  <div className="stg:space-y-4">
312
315
  {groups.map((group) => (
313
316
  <div key={group.label}>
314
- <p className="stg:text-sidebar-muted-foreground stg:mb-1 stg:px-2 stg:text-[10px] stg:font-medium stg:tracking-wider stg:uppercase">
317
+ {/* One step below the section label: same voice, 10px. */}
318
+ <p className="stg:text-sidebar-muted-foreground stg:mb-1 stg:px-2 stg:text-[10px] stg:font-medium stg:tracking-wide stg:uppercase">
315
319
  {group.label}
316
320
  </p>
317
321
  <ul className={`${UNSTYLED_LIST} stg:space-y-0.5`} role="list">
@@ -404,11 +408,15 @@ const ActivityEntry = memo(function ActivityEntry({
404
408
  );
405
409
  });
406
410
 
407
- /** Recents rows are denser than primary nav: 12px text, top-aligned icon. */
411
+ /**
412
+ * Recents rows are denser than primary nav: 12px text, top-aligned icon.
413
+ * Same state grammar as `navRowClassName` — muted at rest, accent fill
414
+ * when active, constant weight so activation never shifts text width.
415
+ */
408
416
  function cnActivityRow(active: boolean): string {
409
417
  return active
410
- ? "stg:flex stg:items-start stg:gap-2 stg:rounded-lg stg:px-2 stg:py-1.5 stg:text-xs stg:transition-colors stg:bg-sidebar-accent stg:text-sidebar-accent-foreground stg:font-medium"
411
- : "stg:flex stg:items-start stg:gap-2 stg:rounded-lg stg:px-2 stg:py-1.5 stg:text-xs stg:transition-colors stg:text-sidebar-foreground stg:hover:bg-sidebar-accent stg:hover:text-sidebar-accent-foreground";
418
+ ? "stg:flex stg:items-start stg:gap-2 stg:rounded-md stg:px-2 stg:py-1 stg:text-xs stg:transition-colors stg:bg-sidebar-accent stg:text-sidebar-accent-foreground"
419
+ : "stg:flex stg:items-start stg:gap-2 stg:rounded-md stg:px-2 stg:py-1 stg:text-xs stg:transition-colors stg:text-sidebar-muted-foreground stg:hover:bg-sidebar-accent stg:hover:text-sidebar-accent-foreground";
412
420
  }
413
421
 
414
422
  function RecentsSkeletons() {
@@ -85,15 +85,19 @@ describe("SettingsSidebar", () => {
85
85
  expect(inactive.getAttribute("aria-current")).toBeNull();
86
86
  });
87
87
 
88
- it("renders Back to Sessions as a muted exit row targeting backHref", () => {
88
+ it("renders Back to Sessions as a resting (never-active) row targeting backHref", () => {
89
89
  const { container } = renderSidebar(
90
90
  <SettingsSidebar {...baseProps()} backHref="/sessions/ses_123" />,
91
91
  );
92
92
 
93
93
  const back = container.querySelector('[data-row-id="back-to-sessions"]')!;
94
94
  expect(back.getAttribute("href")).toBe("/sessions/ses_123");
95
- expect(back.className).toContain("stg:text-sidebar-muted-foreground");
96
95
  expect(back.textContent).toContain("Back to Sessions");
96
+ // An exit, not a destination: always the resting-row treatment, never
97
+ // the active accent — even though it is the row you "came from".
98
+ expect(back.getAttribute("aria-current")).toBeNull();
99
+ expect(back.className.split(" ")).toContain("stg:text-sidebar-muted-foreground");
100
+ expect(back.className.split(" ")).not.toContain("stg:bg-sidebar-accent");
97
101
  });
98
102
 
99
103
  it("renders the footer slot", () => {
@@ -9,8 +9,9 @@ import { OrgSwitcher } from "../organization/OrgSwitcher.js";
9
9
  // ---------------------------------------------------------------------------
10
10
  // Shared sidebar chrome — the pieces WorkspaceSidebar and SettingsSidebar
11
11
  // have in common: the nav container, the top row (collapse toggle + org
12
- // switcher), the hairline separator, the footer band, and the one nav-row
13
- // class recipe. Internal to the sidebar domain; not exported from the SDK.
12
+ // switcher), the hairline separator, the footer band, and the shared class
13
+ // recipes (nav rows, section labels). Internal to the sidebar domain; not
14
+ // exported from the SDK.
14
15
  //
15
16
  // Every class here is transcribed from the console's sidebars — this file
16
17
  // IS the console's chrome now, so a change here changes the console, the
@@ -107,23 +108,36 @@ export function SidebarSeparator() {
107
108
  }
108
109
 
109
110
  /**
110
- * The one nav-row class recipe both sidebars share: 14px medium labels,
111
- * 16px icons (sized by callers), 8px/6px padding, rounded row highlight.
111
+ * The one nav-row class recipe both sidebars share: 13px/16px regular
112
+ * labels, 16px icons (sized by callers), 8px/4px padding 24px rows.
113
+ * The explicit `leading-4` is load-bearing: without it the inherited
114
+ * line-height (~1.5) pads every row to ~28px, and across a full column
115
+ * of rows that difference alone decides whether the sidebar breathes.
116
+ * 24px also stays exactly at the WCAG 2.5.8 minimum pointer-target size.
112
117
  *
113
- * `muted` renders the resting label in the muted sidebar tone used by
114
- * "Back to Sessions", which is an exit, not a destination.
118
+ * Resting rows are muted so the navigation recedes and the content leads;
119
+ * the active row carries the accent fill, hover brightens to the accent
120
+ * foreground. Font weight is constant across states — state is expressed
121
+ * through color and fill only, so activating a row never shifts its text
122
+ * width. The muted-on-sidebar pairing is held to WCAG AA by the theme's
123
+ * contrast contract (sdk/theme/src/contract/pairs.ts) in every preset.
115
124
  */
116
- export function navRowClassName(
117
- active: boolean,
118
- { muted = false }: { muted?: boolean } = {},
119
- ): string {
125
+ export function navRowClassName(active: boolean): string {
120
126
  return cn(
121
- "stg:flex stg:items-center stg:gap-2 stg:rounded-lg stg:px-2 stg:py-1.5 stg:text-sm stg:font-medium stg:transition-colors",
127
+ "stg:flex stg:items-center stg:gap-2 stg:rounded-md stg:px-2 stg:py-1 stg:text-[13px] stg:leading-4 stg:transition-colors",
122
128
  active
123
129
  ? "stg:bg-sidebar-accent stg:text-sidebar-accent-foreground"
124
- : cn(
125
- muted ? "stg:text-sidebar-muted-foreground" : "stg:text-sidebar-foreground",
126
- "stg:hover:bg-sidebar-accent stg:hover:text-sidebar-accent-foreground",
127
- ),
130
+ : "stg:text-sidebar-muted-foreground stg:hover:bg-sidebar-accent stg:hover:text-sidebar-accent-foreground",
128
131
  );
129
132
  }
133
+
134
+ /**
135
+ * The section-label recipe both sidebars share (settings group labels,
136
+ * the Recents heading): 11px/16px medium uppercase in the muted sidebar
137
+ * tone. Spacing around a label stays with the caller — it is a layout
138
+ * concern; this recipe owns only the typography, so the two sidebars
139
+ * cannot drift.
140
+ */
141
+ export function sectionLabelClassName(): string {
142
+ return "stg:text-sidebar-muted-foreground stg:px-2 stg:text-[11px] stg:leading-4 stg:font-medium stg:tracking-wide stg:uppercase";
143
+ }