Skip to content

Commit a6a7d80

Browse files
committed
Merge branch 'wip/fx30-shared' into wip/platform-org-wiring
2 parents c563810 + 5c05133 commit a6a7d80

98 files changed

Lines changed: 1991 additions & 980 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-platform-views.md‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,26 @@ like that" is not one.
2020
| Job | Use | Never |
2121
| --- | --- | --- |
2222
| Explain a field | Its label, placeholder and options (`Access keys (long-lived)`, `Description (optional)`); `error` for validation, `disabledReason` for a locked control, the dialog's Setup guide for steps | A field `hint` or data `hint` (`check:platform-design` `helper-copy`; a line that must stay is named in `HELPER_COPY_OWNERS`) |
23-
| Explain a dialog, settings page or row | A one-sentence form `Modal` `description`; the page's `description` in `apps/sim/platform/lib/data/settings-navigation.ts`; a concise `description` on every `SettingsRow`, `BooleanRow` and `DetailHeader` (`Select your preferred color theme`) | An About tooltip on a settings row, or a hand-written subtitle `<p>` |
23+
| Explain a dialog, settings page or row | A one-sentence form `Modal` `description`; the page's `description` in `apps/sim/platform/lib/data/settings-navigation.ts` (a workspace's in `apps/sim/platform/lib/data/workspace-settings/navigation.ts`); a concise `description` on every `SettingsRow`, `BooleanRow` and `DetailHeader` (`Select your preferred color theme`) | An About tooltip on a settings row, or a hand-written subtitle `<p>` |
2424
| Settings row choice | A bordered `Select` (`SearchableSelect` for a long list) in `SettingsRow field` | A `ChoiceMenu` or `variant='text'` trigger for a saved preference |
2525
| First read of a view | Render nothing for data-backed content while pending (static chrome renders at once), `FailureNotice` when the read fails, `EmptyState` when it settles empty. Read the query's `status`, not an empty list. An action's own pending label (`pendingLabel`, a Load action's `Loading…`) is feedback, not loading copy | A skeleton, shimmer, placeholder rows or loading copy (`check:platform-design` `loading-copy`); a pending hint, placeholder or detail line; treating empty as loading |
2626
| Failed read | `FailureNotice`, labelled Retry. `intent='warning'` where the read partly answered | A hand-built retry button or `InlineNotice` + `Button` |
2727
| Failed background refresh | `FailureNotice` above the content it keeps | Replacing content already on screen |
2828
| Empty | `EmptyState`, with no create button when the page already has one. A picker's no-match line inside a dialog stays `Muted`, since a block would take over the dialog | `Muted` or a raw `<p>` anywhere else |
2929
| Confirm | `ConfirmDialog` (`target`, `title`, `confirmLabel`, `pendingLabel`, `onConfirm`, `safe` when nothing is lost). Removing a person, revoking an invite and any destructive row action always ask first | `Modal variant='confirm'`, `window.confirm`, an unconfirmed destructive action |
30-
| Save | `SaveActions`, end-aligned in the page flow under the fields it commits: Discard while dirty, then Save. Save is visible and disabled while clean | Save in a header, Save before Discard, a Cancel beside Discard |
30+
| Save | `SettingsSaveBar` (`SaveActions` plus the leave guard), end-aligned in the page flow under the fields it commits: Discard while dirty, then Save. Save is visible and disabled while clean. A create form keeps its own Create {noun} or Add {noun} submit | `SaveActions` or a Discard button outside the bar (`save-owner`), Save in a header, Save before Discard, a Cancel beside Discard |
3131
| Autosave vs Save | One or the other per page. A page with `SaveActions` holds every field in one draft and writes it in one request. List actions with their own dialog, such as adding or removing a member, are not field edits | A page mixing both |
3232
| Create | A primary compact button in `SectionToolbar`, or in the page header on a page with one | Create in both the toolbar and the empty state |
3333
| Search over a section's rows | `SearchField` in the toolbar's `grow` slot, labelled and placeholdered `Search <subject>…` | A hand-built input, or copy that names more than the rows it filters |
3434
| Row | `ActivityRow` for a row that opens something, `SettingsRow` for a row holding a control | A hand-built row |
35-
| List of rows | `RowList` (`dividers` for a list read as one block). `ActivityRow density='comfortable'` in settings | `calc(-1 * var(--ui-row-inset))` around a list; styling a row's height from outside |
35+
| List of rows | `ResourceList`, which bleeds through `PageOverflow`. `ActivityRow density='comfortable'` in settings | `calc(-1 * var(--ui-row-inset))` around a list; styling a row's height from outside |
36+
| Empty settings list | `SettingsList` with `empty`: the `EmptyState` in place of the surface, under the section title | An `EmptyState` inside a `SettingsSurface` or `SettingsSection` (`empty-in-surface`) |
37+
| On-or-off setting | `SwitchRow` | A `Switch` in a `SettingsRow` (`switch-row`) |
3638
| Row actions | `ActionMenu` with icon `I108`, destructive item last with a separator | The same action in the row and its menu |
37-
| Section heading | `SettingsSection title` (h2), `DetailHeader` for a sub-view's way back | A hand-written heading |
39+
| Page heading | `SettingsPageHeader` in a section's main view, `DetailHeader` in a sub-view: exactly one per page, in every state | A heading in the frame, a sub-view's title under the page's own |
40+
| Section heading | `SettingsSection title` (h2) | A hand-written heading |
41+
| Open a sub-view | `useSettingsSubview`: the record's id, or the create flow, in the URL | A detail or create flow in `useState`, which Back and refresh lose |
42+
| One read | `SettingsRead` with the query and its failure message | A hand-built pending, failure and refresh-failure branch |
3843
| Toolbar | `SectionToolbar grow={<SearchField label='Search members' … />}` with its actions as children | A hand-built flex row |
3944

4045
## Failure feedback
@@ -60,5 +65,7 @@ user activation is spent, `cancel` cannot be prevented, so `OverlayDialog` reope
6065

6166
- A section with more than one job splits by job into sibling files, as `settings/_components/sections/access-control/`
6267
does (`group-detail.tsx`, `permission-rows.tsx`, `add-members-modal.tsx`).
63-
- What organization and workspace Settings share is in `_components/settings-parts/`
64-
(`apps/sim/platform/docs/component-ownership.md`); a view's data and logic follow `AGENTS.md` "Data seam".
68+
- What organization and workspace Settings share is in `_components/settings-parts/`: `SettingsBody` and
69+
`SettingsPageHeader` (`settings-page.tsx`), the parts in `settings-parts.tsx`, and saved preferences in
70+
`saved-settings.tsx` (`apps/sim/platform/docs/component-ownership.md`). `AGENTS.md` "Settings pages"
71+
states the frame and when to use each part; a view's data and logic follow `AGENTS.md` "Data seam".

‎.claude/rules/sim-url-state.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ const [skillId, setSkillId] = useQueryState(skillIdParam.key, {
250250
const editingSkill = skillId ? (skills.find((s) => s.id === skillId) ?? null) : null
251251
```
252252

253-
Open the panel/modal only when the id **resolves to a loaded entity** — never gate on the raw param alone, or a dead/stale id (deleted entity, old bookmark) renders a broken detail view and a still-loading list flashes one. A dead id simply falls back to the list; the lingering param is harmless. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see "Suspense boundary" above). A separate "create new" flow has no id and stays in local `useState`.
253+
Open the panel/modal only when the id **resolves to a loaded entity** — never gate on the raw param alone, or a dead/stale id (deleted entity, old bookmark) renders a broken detail view and a still-loading list flashes one. A dead id simply falls back to the list; the lingering param is harmless. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see "Suspense boundary" above). A separate "create new" flow has no id and stays in local `useState`, except in Platform Settings, where a section opens its record and create sub-views through `useSettingsSubview` (`apps/sim/platform/lib/data/settings-subview.ts`), which keeps both in the URL with this push/replace convention.
254254

255255
**Close with `replace`, open with `push`.** Opening pushed a history entry; closing must not push another. Close via the setter's per-call options — `setSkillId(null, { history: 'replace' })` — so Back from the list leaves the page instead of reopening the detail (see `mcp-section.tsx`, `custom-blocks-section.tsx`, `forks-section.tsx`). Secondary params scoped to the detail view (e.g. its active tab, `server-tab`) are cleared in the same close handler with their own setter — nuqs batches same-tick writes into one URL update.
256256

‎.cursor/rules/sim-platform-views.mdc‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,26 @@ like that" is not one.
1818
| Job | Use | Never |
1919
| --- | --- | --- |
2020
| Explain a field | Its label, placeholder and options (`Access keys (long-lived)`, `Description (optional)`); `error` for validation, `disabledReason` for a locked control, the dialog's Setup guide for steps | A field `hint` or data `hint` (`check:platform-design` `helper-copy`; a line that must stay is named in `HELPER_COPY_OWNERS`) |
21-
| Explain a dialog, settings page or row | A one-sentence form `Modal` `description`; the page's `description` in `apps/sim/platform/lib/data/settings-navigation.ts`; a concise `description` on every `SettingsRow`, `BooleanRow` and `DetailHeader` (`Select your preferred color theme`) | An About tooltip on a settings row, or a hand-written subtitle `<p>` |
21+
| Explain a dialog, settings page or row | A one-sentence form `Modal` `description`; the page's `description` in `apps/sim/platform/lib/data/settings-navigation.ts` (a workspace's in `apps/sim/platform/lib/data/workspace-settings/navigation.ts`); a concise `description` on every `SettingsRow`, `BooleanRow` and `DetailHeader` (`Select your preferred color theme`) | An About tooltip on a settings row, or a hand-written subtitle `<p>` |
2222
| Settings row choice | A bordered `Select` (`SearchableSelect` for a long list) in `SettingsRow field` | A `ChoiceMenu` or `variant='text'` trigger for a saved preference |
2323
| First read of a view | Render nothing for data-backed content while pending (static chrome renders at once), `FailureNotice` when the read fails, `EmptyState` when it settles empty. Read the query's `status`, not an empty list. An action's own pending label (`pendingLabel`, a Load action's `Loading…`) is feedback, not loading copy | A skeleton, shimmer, placeholder rows or loading copy (`check:platform-design` `loading-copy`); a pending hint, placeholder or detail line; treating empty as loading |
2424
| Failed read | `FailureNotice`, labelled Retry. `intent='warning'` where the read partly answered | A hand-built retry button or `InlineNotice` + `Button` |
2525
| Failed background refresh | `FailureNotice` above the content it keeps | Replacing content already on screen |
2626
| Empty | `EmptyState`, with no create button when the page already has one. A picker's no-match line inside a dialog stays `Muted`, since a block would take over the dialog | `Muted` or a raw `<p>` anywhere else |
2727
| Confirm | `ConfirmDialog` (`target`, `title`, `confirmLabel`, `pendingLabel`, `onConfirm`, `safe` when nothing is lost). Removing a person, revoking an invite and any destructive row action always ask first | `Modal variant='confirm'`, `window.confirm`, an unconfirmed destructive action |
28-
| Save | `SaveActions`, end-aligned in the page flow under the fields it commits: Discard while dirty, then Save. Save is visible and disabled while clean | Save in a header, Save before Discard, a Cancel beside Discard |
28+
| Save | `SettingsSaveBar` (`SaveActions` plus the leave guard), end-aligned in the page flow under the fields it commits: Discard while dirty, then Save. Save is visible and disabled while clean. A create form keeps its own Create {noun} or Add {noun} submit | `SaveActions` or a Discard button outside the bar (`save-owner`), Save in a header, Save before Discard, a Cancel beside Discard |
2929
| Autosave vs Save | One or the other per page. A page with `SaveActions` holds every field in one draft and writes it in one request. List actions with their own dialog, such as adding or removing a member, are not field edits | A page mixing both |
3030
| Create | A primary compact button in `SectionToolbar`, or in the page header on a page with one | Create in both the toolbar and the empty state |
3131
| Search over a section's rows | `SearchField` in the toolbar's `grow` slot, labelled and placeholdered `Search <subject>…` | A hand-built input, or copy that names more than the rows it filters |
3232
| Row | `ActivityRow` for a row that opens something, `SettingsRow` for a row holding a control | A hand-built row |
33-
| List of rows | `RowList` (`dividers` for a list read as one block). `ActivityRow density='comfortable'` in settings | `calc(-1 * var(--ui-row-inset))` around a list; styling a row's height from outside |
33+
| List of rows | `ResourceList`, which bleeds through `PageOverflow`. `ActivityRow density='comfortable'` in settings | `calc(-1 * var(--ui-row-inset))` around a list; styling a row's height from outside |
34+
| Empty settings list | `SettingsList` with `empty`: the `EmptyState` in place of the surface, under the section title | An `EmptyState` inside a `SettingsSurface` or `SettingsSection` (`empty-in-surface`) |
35+
| On-or-off setting | `SwitchRow` | A `Switch` in a `SettingsRow` (`switch-row`) |
3436
| Row actions | `ActionMenu` with icon `I108`, destructive item last with a separator | The same action in the row and its menu |
35-
| Section heading | `SettingsSection title` (h2), `DetailHeader` for a sub-view's way back | A hand-written heading |
37+
| Page heading | `SettingsPageHeader` in a section's main view, `DetailHeader` in a sub-view: exactly one per page, in every state | A heading in the frame, a sub-view's title under the page's own |
38+
| Section heading | `SettingsSection title` (h2) | A hand-written heading |
39+
| Open a sub-view | `useSettingsSubview`: the record's id, or the create flow, in the URL | A detail or create flow in `useState`, which Back and refresh lose |
40+
| One read | `SettingsRead` with the query and its failure message | A hand-built pending, failure and refresh-failure branch |
3641
| Toolbar | `SectionToolbar grow={<SearchField label='Search members' … />}` with its actions as children | A hand-built flex row |
3742

3843
## Failure feedback
@@ -58,5 +63,7 @@ user activation is spent, `cancel` cannot be prevented, so `OverlayDialog` reope
5863

5964
- A section with more than one job splits by job into sibling files, as `settings/_components/sections/access-control/`
6065
does (`group-detail.tsx`, `permission-rows.tsx`, `add-members-modal.tsx`).
61-
- What organization and workspace Settings share is in `_components/settings-parts/`
62-
(`apps/sim/platform/docs/component-ownership.md`); a view's data and logic follow `AGENTS.md` "Data seam".
66+
- What organization and workspace Settings share is in `_components/settings-parts/`: `SettingsBody` and
67+
`SettingsPageHeader` (`settings-page.tsx`), the parts in `settings-parts.tsx`, and saved preferences in
68+
`saved-settings.tsx` (`apps/sim/platform/docs/component-ownership.md`). `AGENTS.md` "Settings pages"
69+
states the frame and when to use each part; a view's data and logic follow `AGENTS.md` "Data seam".

‎.cursor/rules/sim-url-state.mdc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ const [skillId, setSkillId] = useQueryState(skillIdParam.key, {
243243
const editingSkill = skillId ? (skills.find((s) => s.id === skillId) ?? null) : null
244244
```
245245

246-
Open the panel/modal only when the id **resolves to a loaded entity** — never gate on the raw param alone, or a dead/stale id (deleted entity, old bookmark) renders a broken detail view and a still-loading list flashes one. A dead id simply falls back to the list; the lingering param is harmless. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see "Suspense boundary" above). A separate "create new" flow has no id and stays in local `useState`.
246+
Open the panel/modal only when the id **resolves to a loaded entity** — never gate on the raw param alone, or a dead/stale id (deleted entity, old bookmark) renders a broken detail view and a still-loading list flashes one. A dead id simply falls back to the list; the lingering param is harmless. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see "Suspense boundary" above). A separate "create new" flow has no id and stays in local `useState`, except in Platform Settings, where a section opens its record and create sub-views through `useSettingsSubview` (`apps/sim/platform/lib/data/settings-subview.ts`), which keeps both in the URL with this push/replace convention.
247247

248248
**Close with `replace`, open with `push`.** Opening pushed a history entry; closing must not push another. Close via the setter's per-call options — `setSkillId(null, { history: 'replace' })` — so Back from the list leaves the page instead of reopening the detail (see `mcp-section.tsx`, `custom-blocks-section.tsx`, `forks-section.tsx`). Secondary params scoped to the detail view (e.g. its active tab, `server-tab`) are cleared in the same close handler with their own setter — nuqs batches same-tick writes into one URL update.
249249

‎apps/sim/app/(product)/_components/live-shell/live-shell.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type ReactNode, Suspense } from 'react'
22
import { cookies } from 'next/headers'
3-
import { redirect } from 'next/navigation'
3+
import { notFound, redirect } from 'next/navigation'
44
import { loginHref, WORKSPACES_PATH } from '@/lib/navigation/paths'
55
import { resolveLiveDestinations } from '@/app/(product)/_components/destinations'
66
import { LiveComposerHost } from '@/app/(product)/_components/live-shell/live-composer-host'
@@ -13,6 +13,7 @@ import { startShellPrefetch } from '@/platform/lib/data/prefetch-shell'
1313
import { LiveProviders } from '@/platform/lib/data/providers'
1414
import {
1515
followProductRoute,
16+
isClosedSettingsSection,
1617
type ProductLocation,
1718
readDocumentLocation,
1819
} from '@/platform/lib/data/route-resolution'
@@ -45,7 +46,8 @@ async function retiredOrganizationHref({
4546
* organization layout's gates, in its order, against data read in process. Each gate decides
4647
* before anything product-shaped renders. The page below streams inside the Suspense boundary, so a
4748
* redirect it raised would paint this shell first; the document's own route resolution
48-
* (`followProductRoute`) runs here instead, and answers with one HTTP redirect.
49+
* (`followProductRoute`) runs here instead, and answers with one HTTP redirect, as does a Settings
50+
* section the viewer may not open, with one 404 (`isClosedSettingsSection`).
4951
*/
5052
export async function LiveShell({
5153
telemetry,
@@ -87,6 +89,7 @@ export async function LiveShell({
8789
location && followProductRoute(organization, location),
8890
shellPrefetch.finish(organization),
8991
])
92+
if (location && isClosedSettingsSection(organization, location)) notFound()
9093

9194
return (
9295
<LiveProviders
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
'use client'
2+
3+
import { SaveActions } from '@sim/emcn/settings-section/save-actions'
4+
import { useSettingsUnsavedGuard } from '@/platform/lib/data/settings-guard'
5+
6+
interface SettingsSaveBarProps {
7+
dirty: boolean
8+
saving: boolean
9+
/** Save cannot run for a reason the page already shows, such as a field's error. */
10+
blocked?: boolean
11+
/** Why Save is blocked, for a reason the page does not already show. */
12+
blockedReason?: string
13+
/** Omitted inside a `<form>` that owns validation: Save then submits it. */
14+
onSave?: () => void
15+
onDiscard: () => void
16+
}
17+
18+
/**
19+
* The one way a settings draft is committed: `SaveActions` (Discard while dirty, then Save,
20+
* end-aligned under the fields) plus the draft's registration with the Settings leave guard, so
21+
* leaving with unsaved changes asks first and a pending save holds navigation. A sub-view's back
22+
* action leaves through `requestSettingsLeave`, which asks the same guard. Create forms keep their
23+
* own submit button (Create {noun}, Add {noun}): Save is the only label this bar has.
24+
*/
25+
export function SettingsSaveBar({
26+
dirty,
27+
saving,
28+
blocked,
29+
blockedReason,
30+
onSave,
31+
onDiscard,
32+
}: SettingsSaveBarProps) {
33+
useSettingsUnsavedGuard({ isDirty: dirty, navigationBlocked: saving, onDiscard })
34+
return (
35+
<SaveActions
36+
dirty={dirty}
37+
saving={saving}
38+
blocked={blocked}
39+
blockedReason={blockedReason}
40+
onSave={onSave}
41+
onDiscard={onDiscard}
42+
/>
43+
)
44+
}

0 commit comments

Comments
 (0)