Skip to content

Commit 80a01f0

Browse files
committed
improvement(settings): consolidate resource UI onto shared primitives
Sandboxes, MCP tools, and MCP servers each modeled their list rows and detail headers differently. Collapse them — and the surfaces they drifted from — onto one set of primitives. SettingsResourceRow now owns the row entirely: tile, title/subtitle tokens, padding and bleed, hover band, hit area, focus ring, and the one navigation chevron. Adds onClick/href (a stretched overlay, so interactive trailing controls keep their clicks), navigable, iconVariant='custom', and a badge slot for decoration that must not swallow row clicks. Rows that open a detail page get a chevron and a whole-row click; flat records keep the "..." menu. Delete moves to the detail header as a destructive chip behind a confirm modal — sandboxes previously deleted with no confirmation at all. Also folds in SettingsField (new), SettingsEmptyState tone='error', RESOURCE_LIST_STACK/GRID, RESOURCE_TILE_PLAIN, and a shared MemberAvatar; deletes DetailIconTile (byte-identical to ResourceTile); and standardizes on the emcn ArrowRight, which is a visibly different glyph from lucide's.
1 parent 5460133 commit 80a01f0

59 files changed

Lines changed: 1036 additions & 950 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/sim-settings-pages.md

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -107,19 +107,14 @@ token (if the pixel value matches one exactly) or a sign the page never migrated
107107
grep `text-\[1[0-8]px\]` under `apps/sim/app/workspace/*/settings/**` and
108108
`apps/sim/ee/**` to find stragglers.
109109

110-
For a two-line list row (title/value on top, a muted subtitle below — a name +
111-
email, a tool name + description, a server name + status), the established
112-
pairing is:
110+
Watch `text-xs`: it is 11px here, so a "caption" written as `text-xs` is a pixel
111+
short. See `sim-styling.md` for the full scale.
113112

114-
- **Title / row value**: `text-[var(--text-body)] text-sm`
115-
- **Subtitle / muted description**: `text-[var(--text-muted)] text-caption`
116-
117-
This is not a stylistic guess — it is the tokenized form of the literal-pixel
118-
pairing (`text-[14px] text-[var(--text-body)]` / `text-[12px]
119-
text-[var(--text-muted)]`) already used for this exact row shape across
120-
`member-list.tsx`, `api-keys.tsx`, `mcp.tsx`, `billing.tsx`,
121-
`workflow-mcp-servers.tsx`, and others — keep new rows consistent with it rather
122-
than inventing a new size pairing.
113+
The two-line list row (title over a muted subtitle — a name + email, a tool name
114+
+ description, a server name + status) is **not something you build**: it is
115+
`SettingsResourceRow`, which owns the pairing
116+
(`text-[var(--text-body)] text-sm` over `text-[var(--text-muted)] text-caption`).
117+
See "The resource row" below.
123118

124119
For a toggle row (a `Switch` with a title and optional description), use the emcn
125120
`Label` component for the title — never a hand-rolled `<span>` — paired with
@@ -145,21 +140,98 @@ independently-defined tokens (not interchangeable — they resolve to different
145140
colors) and both see legitimate use across settings pages; this rule only pins
146141
down the **row title/subtitle** shape above, not every text element on every page.
147142

143+
## The resource row
144+
145+
**`SettingsResourceRow`** (`…/components/settings-resource-row`) is *the* list row
146+
for every settings resource — and for skills, integrations, and the `ee/` surfaces
147+
too. It owns the tile, the title/subtitle tokens, the row padding and bleed
148+
(`-mx-2 … rounded-lg p-2`), the hover band, the hit area, the focus ring, and the
149+
navigation chevron. Never hand-roll any of it, and never wrap the row in your own
150+
`<button>` or `<Link>` — that is what `onClick`/`href` are for.
151+
152+
```tsx
153+
<div className={RESOURCE_LIST_STACK}>
154+
{items.map((item) => (
155+
<SettingsResourceRow
156+
key={item.id}
157+
icon={<Wrench className='text-[var(--text-icon)]' />}
158+
iconFilled
159+
title={item.name}
160+
description={item.summary}
161+
onClick={() => open(item.id)} // or href={`…/${item.id}`}
162+
clickLabel={`Open ${item.name}`}
163+
navigable
164+
/>
165+
))}
166+
</div>
167+
```
168+
169+
- `icon?` + `iconVariant``tile` (default, the 36px bordered tile), `plain` (a
170+
bare 14px glyph), `custom` (you supply the whole tile, e.g. the brand-tinted
171+
`IntegrationTile`). Omit `icon` entirely for resources with no identity glyph
172+
(an API key, a permission group). `iconFilled` uses the skills/tools fill;
173+
`iconFill` lets an uploaded image reach the tile edge.
174+
- `onClick` / `href` — makes the **whole row** activatable via a stretched
175+
overlay. Prefer `href` when the destination is a route, so the row keeps
176+
prefetch, middle-click, and open-in-new-tab. `clickLabel` is the accessible
177+
name and is **required** alongside either: the overlay has no text of its own.
178+
- `navigable` — appends the one canonical chevron. Set it on rows that open a
179+
detail page; leave it off when `onClick` acts in place (revealing a folder).
180+
Never import an arrow yourself: `lucide-react` and `@sim/emcn/icons` ship
181+
visibly different glyphs, and the row already picked one.
182+
- `trailing` vs `badge``trailing` is for **interactive** controls (a `Chip`, a
183+
`RowActionsMenu`) and sits above the hit area. `badge` is for **decoration** (a
184+
status tag) and is click-through. Putting a badge in `trailing` turns the row's
185+
right edge into a dead zone.
186+
- `RESOURCE_LIST_STACK` / `RESOURCE_LIST_GRID` — the single-column and two-up
187+
containers. A `SettingsResourceRow` carries its own `-mx-2` bleed and padding,
188+
so a container holding one only sets rhythm: never add a second `-mx-2` (they
189+
stack into a 16px bleed) and never a different gap. A list of hand-rolled rows
190+
is the opposite — there the container owns the bleed. Note `-mx-2` inside a
191+
fixed-height `overflow-y-auto` box forces a horizontal scrollbar; drop the
192+
bleed there.
193+
194+
**Three-dots vs. chevron** is not a taste call:
195+
196+
- Opens a **detail page**`navigable` + a whole-row click, and no `Delete` in
197+
the row (it lives in the detail header).
198+
- **No detail page** → a `RowActionsMenu` in `trailing`, and no `navigable`
199+
chevron. The row may still take an `onClick` for an in-place action — a folder
200+
mount reveals itself in Finder and also carries a `...` menu — but a row must
201+
never offer both a chevron and a menu.
202+
148203
## Other shared settings primitives (do not re-roll these)
149204

205+
- **`SettingsSection`** (`…/components/settings-section`) — muted label, hairline
206+
divider, body. Also carries `headerAccessory` and `action` slots. Never
207+
re-derive the label/divider chrome; `sim-styling.md` owns those tokens.
208+
- **`SettingsField`** (`…/components/settings-field`) — a read-only label/value
209+
pair in a detail body: muted caption over the value. Pair it with
210+
`SETTINGS_FIELD_VALUE_CLASSES` for the value text.
150211
- **`SettingsEmptyState`** (`…/components/settings-empty-state`) — the canonical
151-
muted status message. `variant='fill'` (default) centers in the available
152-
height (empty list, or a not-entitled/loading gate); `variant='inline'` sits in
153-
flow (a search "no results"). Never hand-roll
154-
`<div className='flex h-full items-center justify-center text-[var(--text-muted)] text-sm'>`
155-
or `<div className='py-4 text-center …'>`. It owns the `--text-muted` + `text-sm`
156-
tokens, so it also keeps these messages consistent across pages.
212+
muted status message, for empty lists, "no results", loading gates, **and
213+
failed loads** (`tone='error'`). `variant='fill'` (default) centers in the
214+
available height; `variant='inline'` sits in flow. Never hand-roll
215+
`<div className='flex h-full items-center justify-center …'>` or
216+
`<div className='py-4 text-center …'>`.
157217
- **`RowActionsMenu`** (`…/components/row-actions-menu`) — the trailing `...`
158218
actions menu for a list row. Pass `label` (aria-label) and
159219
`actions: RowAction[]` (`{ label, onSelect, destructive?, disabled? }`); the
160220
component renders the canonical flush `...` trigger + `DropdownMenuContent`.
161221
Conditional items become array spreads: `...(canManage ? [{…}] : [])`. Never
162222
hand-roll the `<DropdownMenu>` + `<MoreHorizontal>` trigger per page.
223+
- **`RESOURCE_TILE_BASE`** + one of `RESOURCE_TILE_FILL` / `RESOURCE_TILE_PLAIN`
224+
(`…/components/resource-tile`) — the 36px tile chrome, for the rare tile
225+
outside a row (a detail heading). `ResourceTile` wraps the filled pairing.
226+
- **`MemberAvatar`** (`@/components/permissions/member-avatar`) — the one avatar
227+
for any member row.
228+
229+
## Deleting a resource
230+
231+
Delete lives in the **detail header**, as
232+
`{ text: 'Delete', variant: 'destructive', onSelect: … }` behind a
233+
`ChipConfirmModal` — never `textTone: 'error'`, never a bare `Chip`, and never
234+
unconfirmed. A list row does not carry Delete when the resource has a detail page.
163235

164236
## Save / Discard + unsaved-changes guard
165237

@@ -246,4 +318,9 @@ A settings page is design-system-clean when:
246318
- [ ] If it has editable state: Save/Discard go through `SaveDiscardActions`, dirty is wired via `useSettingsUnsavedGuard` (called before any early-return gate), and there is **no** hand-rolled Save button / `beforeunload` / "Unsaved changes" modal.
247319
- [ ] No business logic, handlers, or conditional rendering changed by the migration.
248320
- [ ] No literal `text-[Npx]` classes — named scale tokens only (see "Text-scale tokens" above).
321+
- [ ] Every **resource** list row (a thing with an identity — a tool, a server, a key, a credential) is a `SettingsResourceRow` in a `RESOURCE_LIST_STACK`/`RESOURCE_LIST_GRID` — no wrapper `<button>`/`<Link>`, no hand-passed arrow, no re-derived title/subtitle spans. Rows with a genuinely different shape stay bespoke: multi-line bodies (inbox tasks), tabular columns (billing invoices, credit usage), and grids (secrets).
322+
- [ ] Rows that open a detail page use `navigable` + `clickLabel`; flat records use `RowActionsMenu`. Not both.
323+
- [ ] Decorative trailing content is in `badge`, not `trailing`.
324+
- [ ] Labeled sections use `SettingsSection`; read-only fields use `SettingsField`; empty/loading/error use `SettingsEmptyState`.
325+
- [ ] Delete is a `destructive` header action behind a `ChipConfirmModal`.
249326
- [ ] `tsc`, `biome`, and the page's tests pass.
Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ReactNode } from 'react'
2+
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
23

34
interface CredentialDetailHeadingProps {
45
/** Leading visual (icon tile or brand tile). */
@@ -8,23 +9,16 @@ interface CredentialDetailHeadingProps {
89
}
910

1011
/**
11-
* Header row shared by credential detail surfaces: a leading visual beside a
12-
* title over a muted subtitle.
12+
* Header row shared by credential detail surfaces. A thin alias over the static
13+
* {@link SettingsResourceRow} — the heading and the list row the user arrived
14+
* from are the same object, so they must not drift.
1315
*/
1416
export function CredentialDetailHeading({
1517
leading,
1618
title,
1719
subtitle,
1820
}: CredentialDetailHeadingProps) {
1921
return (
20-
<div className='flex items-center gap-2.5'>
21-
{leading}
22-
<div className='flex min-w-0 flex-1 flex-col'>
23-
<span className='truncate text-[14px] text-[var(--text-body)]'>{title}</span>
24-
{subtitle ? (
25-
<span className='truncate text-[12px] text-[var(--text-muted)]'>{subtitle}</span>
26-
) : null}
27-
</div>
28-
</div>
22+
<SettingsResourceRow iconVariant='custom' icon={leading} title={title} description={subtitle} />
2923
)
3024
}

apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ export function CredentialDetailLayout({ back, actions, children }: CredentialDe
2121
<div className='flex h-full flex-col bg-[var(--bg)]'>
2222
<div className={cn(PAGE_HEADER_BAR, 'justify-between')}>
2323
{back}
24-
{actions ? <div className='flex items-center'>{actions}</div> : null}
24+
{actions ? <div className='flex h-[30px] items-center gap-1'>{actions}</div> : null}
2525
</div>
2626
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
27-
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>{children}</div>
27+
<div className='mx-auto flex w-full max-w-[48rem] flex-col gap-7 pb-6'>{children}</div>
2828
</div>
2929
</div>
3030
)

apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/detail-icon-tile.tsx

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,16 @@
11
import type { ReactNode } from 'react'
2+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
23

34
interface DetailSectionProps {
45
title: ReactNode
56
children: ReactNode
67
}
78

89
/**
9-
* Labeled section with a muted title and a thin inset divider above the body.
10-
* Shared by the credential detail surfaces so every section keeps the same
11-
* vertical rhythm without repeating markup at the callsites.
10+
* Labeled section for the credential detail surfaces. A thin alias over the
11+
* shared {@link SettingsSection} so credential pages and settings pages cannot
12+
* drift apart; kept for its `title` naming at the existing callsites.
1213
*/
1314
export function DetailSection({ title, children }: DetailSectionProps) {
14-
return (
15-
<section className='flex flex-col'>
16-
<span className='pl-0.5 text-[var(--text-muted)] text-small'>{title}</span>
17-
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
18-
{children}
19-
</section>
20-
)
15+
return <SettingsSection label={title}>{children}</SettingsSection>
2116
}

apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ export { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL } from './components/chip-field'
33
export { CredentialDetailHeading } from './components/credential-detail-heading'
44
export { CredentialDetailLayout } from './components/credential-detail-layout'
55
export { CredentialMembersSection } from './components/credential-members-section'
6-
export { DetailIconTile } from './components/detail-icon-tile'
76
export { DetailSection } from './components/detail-section'
87
export { UnsavedChangesModal } from './components/unsaved-changes-modal'
98
export { useCredentialDetailForm } from './hooks/use-credential-detail-form'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {
22
RESOURCE_TILE_BASE,
33
RESOURCE_TILE_FILL,
4+
RESOURCE_TILE_PLAIN,
45
ResourceTile,
56
} from '@/app/workspace/[workspaceId]/components/resource-tile/resource-tile'

apps/sim/app/workspace/[workspaceId]/components/resource-tile/resource-tile.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export const RESOURCE_TILE_BASE =
1717
/** Filled treatment worn by the skills and custom tools resource tiles. */
1818
export const RESOURCE_TILE_FILL = 'bg-[var(--surface-4)] dark:bg-[var(--surface-5)]'
1919

20+
/** Page-background fill, for tiles holding a brand logo or a site favicon. */
21+
export const RESOURCE_TILE_PLAIN = 'bg-[var(--bg)]'
22+
2023
/**
2124
* Square glyph tile identifying a workspace resource — the leading visual on a
2225
* resource's row and on its detail heading.

apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx

Lines changed: 32 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
import { useEffect, useMemo, useRef, useState } from 'react'
44
import { Chip, ChipDropdown, ChipLink, cn } from '@sim/emcn'
5-
import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'
6-
import Link from 'next/link'
5+
import { ArrowLeft, Plus } from 'lucide-react'
76
import { useRouter } from 'next/navigation'
87
import { useQueryState } from 'nuqs'
98
import { PAGE_HEADER_BAR } from '@/components/page-header-bar'
@@ -16,6 +15,7 @@ import {
1615
} from '@/lib/integrations'
1716
import { credentialProviderMatchesService } from '@/lib/oauth'
1817
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
18+
import { RESOURCE_TILE_BASE } from '@/app/workspace/[workspaceId]/components/resource-tile'
1919
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
2020
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
2121
import {
@@ -26,6 +26,11 @@ import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/c
2626
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
2727
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
2828
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
29+
import {
30+
RESOURCE_LIST_STACK,
31+
SettingsResourceRow,
32+
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
33+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
2934
import { getTileIconColorClass } from '@/blocks/icon-color'
3035
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
3136
import {
@@ -198,10 +203,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
198203
<IntegrationTile blockType={integration.type} icon={Icon} />
199204
) : (
200205
<div
201-
className={cn(
202-
'flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)]',
203-
getTileIconColorClass(integration.bgColor)
204-
)}
206+
className={cn(RESOURCE_TILE_BASE, getTileIconColorClass(integration.bgColor))}
205207
style={{ background: integration.bgColor }}
206208
>
207209
{integration.name.charAt(0)}
@@ -216,22 +218,18 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
216218
{connectedCredentials.length > 0 && (
217219
<IntegrationSection label='Connected'>
218220
{connectedCredentials.map((credential) => (
219-
<Link
221+
<SettingsResourceRow
220222
key={credential.id}
223+
iconVariant='custom'
224+
icon={Icon && <IntegrationTile blockType={integration.type} icon={Icon} />}
225+
title={credential.displayName}
226+
description={
227+
credential.description || resolveCredentialDisplay(credential).subtitle
228+
}
221229
href={`/workspace/${workspaceId}/integrations/connected/${credential.id}`}
222-
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
223-
>
224-
{Icon && <IntegrationTile blockType={integration.type} icon={Icon} />}
225-
<div className='flex min-w-0 flex-1 flex-col'>
226-
<span className='truncate text-[14px] text-[var(--text-body)]'>
227-
{credential.displayName}
228-
</span>
229-
<span className='truncate text-[12px] text-[var(--text-muted)]'>
230-
{credential.description || resolveCredentialDisplay(credential).subtitle}
231-
</span>
232-
</div>
233-
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
234-
</Link>
230+
clickLabel={`Open ${credential.displayName}`}
231+
navigable
232+
/>
235233
))}
236234
</IntegrationSection>
237235
)}
@@ -274,10 +272,8 @@ function TemplatesSection({ integration, templates, workspaceId }: TemplatesSect
274272
}
275273

276274
return (
277-
<section className='flex flex-col'>
278-
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Templates</span>
279-
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
280-
<div className='-mx-2 flex flex-col gap-y-0.5'>
275+
<SettingsSection label='Templates'>
276+
<div className={RESOURCE_LIST_STACK}>
281277
{templates.map((template) => {
282278
const blockTypes = [integration.type, ...template.otherBlockTypes].slice(
283279
0,
@@ -294,7 +290,7 @@ function TemplatesSection({ integration, templates, workspaceId }: TemplatesSect
294290
)
295291
})}
296292
</div>
297-
</section>
293+
</SettingsSection>
298294
)
299295
}
300296

@@ -306,25 +302,21 @@ interface TemplateRowProps {
306302
}
307303

308304
/**
309-
* Template row that mirrors `IntegrationItem` from the integrations index
310-
* byte-for-byte (icon cluster · title · description · trailing `ArrowRight`).
311-
* Renders as a `<button>` because click seeds the home page chat with `prompt`
312-
* and navigates to the workspace home, matching the `ShowcaseWithExplore` flow.
305+
* Template row. Click seeds the home page chat with `prompt` and navigates to the
306+
* workspace home, matching the `ShowcaseWithExplore` flow — so it activates rather
307+
* than links, and its leading visual is an overlapping block cluster.
313308
*/
314309
function TemplateRow({ blockTypes, title, prompt, onSelect }: TemplateRowProps) {
315310
return (
316-
<button
317-
type='button'
311+
<SettingsResourceRow
312+
iconVariant='custom'
313+
icon={<TemplateIcons blockTypes={blockTypes} />}
314+
title={title}
315+
description={prompt}
318316
onClick={() => onSelect(prompt)}
319-
className='group flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
320-
>
321-
<TemplateIcons blockTypes={blockTypes} />
322-
<div className='flex min-w-0 flex-1 flex-col'>
323-
<span className='truncate text-[14px] text-[var(--text-body)]'>{title}</span>
324-
<span className='truncate text-[12px] text-[var(--text-muted)]'>{prompt}</span>
325-
</div>
326-
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
327-
</button>
317+
clickLabel={`Use template ${title}`}
318+
navigable
319+
/>
328320
)
329321
}
330322

0 commit comments

Comments
 (0)