Skip to content

Commit 9cc1172

Browse files
committed
feat(platform): enforce confirm, copy, back, tab, alert and tooltip owners
check:platform-design gains confirm-owner (no hand-built confirm Modal), copy-case (sentence case for names, menu, column and command labels and button text, with a proper-noun and product-phrase list), back-owner (no back-glyph text buttons outside DirectoryBack), tab-owner and alert-owner (no hand-written tab, tablist or alert roles) and native-title (no title= tooltip on a DOM element), and copy-ellipsis now reads object and record values. Exceptions carry their reasons in CONFIRM_OWNERS, BACK_OWNERS, TAB_OWNERS and ALERT_OWNERS. The rules are documented in the Platform contract and sim-ui-copy.
1 parent be194d3 commit 9cc1172

8 files changed

Lines changed: 587 additions & 3 deletions

File tree

‎.claude/rules/sim-ui-copy.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ paths:
1515

1616
This applies to app UI outside the Platform: settings rows, modals, panels, cards, list rows, empty states, form fields, and section headers. Platform views (`app/(product)`, `platform`) follow the stricter one-line-per-control rule in `apps/sim/platform/AGENTS.md`, which `check:platform-design` enforces (`helper-copy`); marketing surfaces (`app/(landing)`, docs) follow `constitution.md`.
1717

18+
## Platform copy vocabulary
19+
20+
Platform views also share one vocabulary, documented in `apps/sim/platform/AGENTS.md` (Code-first product design) and enforced by `check:platform-design`:
21+
22+
- **Sentence case** for every literal `label`, `title`, `confirmLabel`, `pendingLabel` and `cancelLabel`, for `label`s in menu, column, sort and command data, and for button, menu item and chip text (`copy-case`). Product, plan and brand names keep their capitals through `PROPER_NOUNS` / `PROPER_PHRASES` in `scripts/platform-source-rules.ts`; a page or tab name inside a label is a common noun ("Open logs", "Switch to runs").
23+
- **The `…` glyph**, never three periods, in rendered copy and in object or record values such as status labels (`copy-ellipsis`).
24+
- **Confirmations** go through `ConfirmDialog` with a real `pendingLabel` ("Deleting…"), `safe` when nothing is lost (`confirm-owner`). Titles are "{Verb} {noun}" with no question mark, identical for one object everywhere; unsaved changes read "Unsaved changes" / "Discard".
25+
- **Verbs**: editing saves with "Save", creating says "Create {noun}", adding a connection or credential says "Add {noun}", OAuth keeps "Connect".
26+
- **Empty and no-results**: an empty list is `EmptyState` (`Muted` in a narrow rail or dialog picker); no results read "No matching {noun}", the query in curly quotes, no period.
27+
- **Back, tabs, alerts, tooltips** belong to their owners: `DirectoryBack` / `DetailHeader` with bare-noun labels (`back-owner`), `ContentTabs` (`tab-owner`), `InlineNotice` / `FailureNotice` / a field's `error` (`alert-owner`), and `Tooltip` / `OverflowText` rather than a native `title=` (`native-title`). Statuses use Logs' words: a failed run reads "Failed".
28+
1829
**Carve-out — settings section metadata.** `SettingsNavigationItem.description` in `components/settings/navigation.ts` stays required, and `SettingsPanel` keeps rendering it as the page subtitle. Settings sections are reached through a nav list where the description is the only thing distinguishing adjacent sections, so it earns its place by the "prevents misunderstanding" test. Keep those descriptions verb-first and one line, per `sim-settings-pages.md`. Everything else on a settings page — inline `<p>` blurbs under section headings, field hints, modal bodies, row subtitles — follows the default rule above.
1930

2031
## The default is no description

‎.cursor/rules/sim-ui-copy.mdc‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ globs: ["apps/sim/components/**/*.tsx","apps/sim/ee/**/*.tsx","apps/sim/app/_she
1111

1212
This applies to app UI outside the Platform: settings rows, modals, panels, cards, list rows, empty states, form fields, and section headers. Platform views (`app/(product)`, `platform`) follow the stricter one-line-per-control rule in `apps/sim/platform/AGENTS.md`, which `check:platform-design` enforces (`helper-copy`); marketing surfaces (`app/(landing)`, docs) follow `constitution.md`.
1313

14+
## Platform copy vocabulary
15+
16+
Platform views also share one vocabulary, documented in `apps/sim/platform/AGENTS.md` (Code-first product design) and enforced by `check:platform-design`:
17+
18+
- **Sentence case** for every literal `label`, `title`, `confirmLabel`, `pendingLabel` and `cancelLabel`, for `label`s in menu, column, sort and command data, and for button, menu item and chip text (`copy-case`). Product, plan and brand names keep their capitals through `PROPER_NOUNS` / `PROPER_PHRASES` in `scripts/platform-source-rules.ts`; a page or tab name inside a label is a common noun ("Open logs", "Switch to runs").
19+
- **The `…` glyph**, never three periods, in rendered copy and in object or record values such as status labels (`copy-ellipsis`).
20+
- **Confirmations** go through `ConfirmDialog` with a real `pendingLabel` ("Deleting…"), `safe` when nothing is lost (`confirm-owner`). Titles are "{Verb} {noun}" with no question mark, identical for one object everywhere; unsaved changes read "Unsaved changes" / "Discard".
21+
- **Verbs**: editing saves with "Save", creating says "Create {noun}", adding a connection or credential says "Add {noun}", OAuth keeps "Connect".
22+
- **Empty and no-results**: an empty list is `EmptyState` (`Muted` in a narrow rail or dialog picker); no results read "No matching {noun}", the query in curly quotes, no period.
23+
- **Back, tabs, alerts, tooltips** belong to their owners: `DirectoryBack` / `DetailHeader` with bare-noun labels (`back-owner`), `ContentTabs` (`tab-owner`), `InlineNotice` / `FailureNotice` / a field's `error` (`alert-owner`), and `Tooltip` / `OverflowText` rather than a native `title=` (`native-title`). Statuses use Logs' words: a failed run reads "Failed".
24+
1425
**Carve-out — settings section metadata.** `SettingsNavigationItem.description` in `components/settings/navigation.ts` stays required, and `SettingsPanel` keeps rendering it as the page subtitle. Settings sections are reached through a nav list where the description is the only thing distinguishing adjacent sections, so it earns its place by the "prevents misunderstanding" test. Keep those descriptions verb-first and one line, per `sim-settings-pages.md`. Everything else on a settings page — inline `<p>` blurbs under section headings, field hints, modal bodies, row subtitles — follows the default rule above.
1526

1627
## The default is no description

‎apps/sim/platform/AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ Measure the actual scroll viewport with `ScrollArea`, or attach `useScrollEdges`
4949

5050
Form dialogs use `Modal` or `MultiStepModal`. Pass a title, content, action labels and handlers. A dialog with tabs keeps its top edge and grows downward, so switching tabs never moves them; its fields keep the dialog's field gap. A confirm handler that returns a promise holds the dialog open as pending, closes it on success, and keeps the draft with an inline message on failure; nothing dismisses it while pending. Returning `false` keeps it open at the dialog's next stage, and `onBack` turns the quiet footer action into a step back. The shared borderless surface positions its title at the logical start and supplies one quiet Cancel/Back. An optional footerStartAction accepts a standard button label, handler, intent and availability at the logical start; Cancel and confirmation remain at the end. Destructive editor actions require their own confirmation and never become the default focus target. Pending submission disables all footer actions. Command search and previews use the same shadow and dismissal layer. Form controls cannot construct or style a form header, footer or Cancel button. Opening a dialog or advancing its step focuses the first available field. Confirmation dialogs focus Cancel, and read-only previews focus Close. A nested dialog handles Escape one layer at a time. While a form is open, the global search shortcut leaves its draft and keyboard focus in place. Closing returns focus to the invoking control. Textareas scroll or grow within their owner and never show a manual resize handle. Modal and panel content uses the shared field family: TextField, TextAreaField, SelectField, SwitchField (a setting that applies with its switch, captioned on the leading edge), SecretField, RecipientField, FileField, DetailsField and ListField. Product dialogs provide values, labels and callbacks; they do not recreate field chrome. Full-width FileField centers its prompt without an upload glyph; its image kind supplies a compact preview and independent removal. DetailsField labels read-only information, and ListField owns grouped bullets and bounded scrolling. Use DataTable inside a dialog only for actual tabular data or selection, not static skill instructions. Recipient fields use the textarea’s height, draft typography and inset. Committed addresses use compact caption text and an always-visible neutral ×, like a panel tab. The remove button sits inside the tag beside its label; long addresses yield space to the button. Comma or Enter commits a draft, pasting a list commits its addresses, and Backspace from an empty draft removes the last tag.
5151

52+
A question before an action is `ConfirmDialog` (`@sim/emcn/modal/confirm-dialog`), never a hand-built `Modal variant='confirm'` (`confirm-owner`): it holds the pending label (`Deleting…`), closes only on success and shows a failure. Pass `safe` for a confirm that loses nothing. Its title is the verb and the object in sentence case with no question mark (Delete link, Remove connection, Unsaved changes), the same for one object on every surface; Unsaved changes confirms with Discard. Editing saves with Save, creating says Create {noun}, adding a connection or credential says Add {noun}, and OAuth keeps Connect. Renames use the shared `RenameDialog`. Interface copy is sentence case: a literal `label`, `title`, `confirmLabel`, `pendingLabel` or `cancelLabel`, a `label` in menu, column, sort or command data, and a button, menu item or chip's text (`copy-case`). Product names (Sim, Chat, Assistant, Search), plans, the Agent block, brands and key names keep their capitals; they are listed, with multi-word names such as Slack Search and Compute Units, in `PROPER_NOUNS` and `PROPER_PHRASES` in `scripts/platform-source-rules.ts`. A page or tab name inside a label is a common noun (Open logs, Switch to runs). `copy-ellipsis` also reads an object or record's string values, so a status or column label writes `…` too. Statuses use Logs' vocabulary: a failed run reads Failed. A page's way back is `DirectoryBack`, and a Settings sub-view's is `DetailHeader`, each labelled with a bare noun (Skills, not Back to Skills); a hand-built text button led by the back glyph is reported (`back-owner`). Tabs are `ContentTabs` (`tab-owner`), and a message announced as an alert is `InlineNotice` with `intent='danger'` for a failed action, `FailureNotice` with Retry for a failed read, or the field's `error` (`alert-owner`): no hand-written `role='tab'`, `'tablist'` or `'alert'`. A full value on hover is `Tooltip` or `OverflowText`, never a native `title=` on a DOM element (`native-title`); an iframe's `title` is its accessible name and stays. An empty list says so with `EmptyState` (or `WorkspaceEmpty` in a workspace tab), and a narrow rail or a dialog's picker with `Muted`; a search that matches nothing reads No matching {noun}, with the query in curly quotes, and no period. Within one control bar, an action a viewer cannot take is hidden, or disabled with a real `disabledReason`. A justified exception is named, with its reason, in `CONFIRM_OWNERS`, `BACK_OWNERS`, `TAB_OWNERS` or `ALERT_OWNERS`.
53+
5254
ContentTabs uses Base UI tab behavior with the same ControlRow lane, spacing and paint as DestinationTabs. Its optional actions sit beside the tab navigation, outside its scroll viewport and arrow-key semantics. DataTable composes those tabs, native table semantics and shared checkboxes for controlled row/select-all operations. Its persistent toolbar places search at the start, filters at the end and sorting farthest right. A reserved selection count and caller-provided selection actions appear before the trailing filters and sort only when rows are selected. At zero selection the group is invisible, inert and excluded from accessibility, while preserving its layout space so neither sorting nor the table moves. Visible actions become disabled when inapplicable. There is no default Clear selection or mutation action. Passive table rows have no whole-row hover or click target; their controls remain independent. Consumers own filtering, sorting and mutation policy. Ordinary table values use regular-weight control text and normal ink; email and supporting values use secondary ink, not a heavier font. People tables use separate name and email columns where space permits, regrouping the identity on compact layouts and placing role controls beneath it at enlarged-text widths. Keep inline avatars, checkboxes and controls centered, and select-all visible in every layout. Column positions remain stable when filtering to zero rows, restoring results or changing selection. Use fluid column allocation and natural text wrapping when space is tight; do not force horizontal scrolling with minimum column widths. Preserve the action column when the table is empty. The table fits its reading column with the shared 8px surface overhang. PropertyList uses the same inline person mark and normal value ink, with intrinsic label tracks and a stacked layout only when space requires it.
5355

5456
MessageBubble owns the normal user-message surface and selectable integration ideas. Idea messages sit centered over full-width monochrome meeting artwork, with a translucent fill and at most two visible lines. Hover and press change the muted fill without adding a border. Selecting one appends the full prompt to the Home draft without submitting or discarding existing text or attachments.

‎scripts/check-platform-design.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,3 +1193,58 @@ it('fails [helper-copy] for a hint, a form subtitle or a static row subtitle, an
11931193
rmSync(root, { recursive: true, force: true })
11941194
}
11951195
}, 30_000)
1196+
1197+
it('fails the ownership and copy rules: confirm-owner, copy-case, copy-ellipsis, back-owner, tab-owner, alert-owner, native-title', () => {
1198+
const { root, put, run } = productFixture()
1199+
const view = 'app/(product)/o/[organizationId]/home/_components/digest/digest.tsx'
1200+
try {
1201+
put(
1202+
view,
1203+
`import { Button } from '@sim/emcn/button'
1204+
import { Modal } from '@sim/emcn/modal'
1205+
const STATUS = { queued: { label: 'Waiting...' } }
1206+
export function Digest({ name, back }: { name: string; back: () => void }) {
1207+
return <>
1208+
<Modal variant='confirm' title='Delete digest' />
1209+
<Button variant='quiet' icon='I093' onClick={back}>Back to home</Button>
1210+
<div role='tablist'><button role='tab'>Runs</button></div>
1211+
<p role='alert'>{name}</p>
1212+
<span title={name}>{name}</span>
1213+
<Button onClick={back}>Fit to View</Button>
1214+
<Button onClick={back}>Connect Slack</Button>
1215+
<iframe title='Preview' />
1216+
<p>{STATUS.queued.label}</p>
1217+
</>
1218+
}`
1219+
)
1220+
const result = run()
1221+
expect(result.status).toBe(1)
1222+
for (const finding of [
1223+
`${view}:3 [copy-ellipsis]`,
1224+
`${view}:6 [confirm-owner]`,
1225+
`${view}:7 [back-owner]`,
1226+
`${view}:8 [tab-owner]`,
1227+
`${view}:9 [alert-owner]`,
1228+
`${view}:10 [native-title]`,
1229+
`${view}:11 [copy-case]`,
1230+
])
1231+
expect(result.output).toContain(finding)
1232+
expect(result.output).toContain('Write View in sentence case')
1233+
expect(result.output.match(/\[tab-owner\]/g)).toHaveLength(2)
1234+
expect(result.output).not.toContain(`${view}:12 [copy-case]`)
1235+
expect(result.output).not.toContain(`${view}:13 [native-title]`)
1236+
1237+
writeAllowlists(root, {
1238+
CONFIRM_OWNERS: { [view]: 'The fixture keeps its confirm to prove a grant' },
1239+
BACK_OWNERS: { [view]: 'The fixture keeps its back button to prove a grant' },
1240+
TAB_OWNERS: { [view]: 'The fixture keeps its tabs to prove a grant' },
1241+
ALERT_OWNERS: { [view]: 'The fixture keeps its alert to prove a grant' },
1242+
})
1243+
const granted = run()
1244+
for (const rule of ['confirm-owner', 'back-owner', 'tab-owner', 'alert-owner'])
1245+
expect(granted.output).not.toContain(`[${rule}]`)
1246+
expect(granted.output).toContain('[native-title]')
1247+
} finally {
1248+
rmSync(root, { recursive: true, force: true })
1249+
}
1250+
}, 30_000)

‎scripts/check-platform-design.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,17 @@ import { ruleDocumentation } from './platform-rule-docs'
2121
import {
2222
classifyPage,
2323
type HelperCopyKind,
24+
handRole,
2425
hasAsciiEllipsis,
2526
helperCopy,
2627
inFixtureBranch,
2728
isFixtureModeBranch,
29+
isHandBack,
30+
isHandConfirm,
2831
isLoadingCopy,
32+
isNativeTitle,
2933
isUnownedLoadFailure,
34+
titleCaseWords,
3035
} from './platform-source-rules'
3136
import { productUsage } from './platform-variant-usage'
3237
import { listRepoFiles } from './repo-files'
@@ -282,6 +287,8 @@ const inRouteTree = (file: string) => file.startsWith(PRODUCT_ROUTES)
282287
* desktop and public-environment pieces the site's root layout loads, so it alone may import them.
283288
*/
284289
const PRODUCT_ROOT_LAYOUT = `${PRODUCT_ROUTES}layout.tsx`
290+
/** `DirectoryBack`, the page-level way back, whose chip carries the back glyph. */
291+
const BACK_OWNER = `${PRODUCT_ROUTES}_components/resource-directory/resource-directory.tsx`
285292
/** The one table cell whose width is a caller's value, carried to its CSS as a custom property. */
286293
const COLUMN_WIDTH_OWNER = `${shared}components/data-display/data-display`
287294
/** The calendar day column, which places each event by its time through an inline style. */
@@ -1047,6 +1054,10 @@ function checkJs(file: string, content: string) {
10471054
file.startsWith('platform/checks/') ||
10481055
/\.test\.[jt]sx?$/.test(file)
10491056
const authoredCopy = !file.startsWith('platform/checks/') && !/\.test\.[jt]sx?$/.test(file)
1057+
/** Product views and helpers, where the copy and ownership rules apply; EMCN is frozen. */
1058+
const ownsProductCopy = inPlatformTree(file) && authoredCopy
1059+
/** Fixture and generated data carries example names and titles, not interface copy. */
1060+
const authoredData = !/(?:^|\/)fixtures\//.test(file) && !/\.generated\.[jt]sx?$/.test(file)
10501061
const reported = new Set<string>()
10511062
/** Reports `rule` once per file unless `allowlist` grants the file. */
10521063
const reportFileOnce = (
@@ -1120,6 +1131,46 @@ function checkJs(file: string, content: string) {
11201131
)
11211132
if (inPlatform && authoredCopy && hasAsciiEllipsis(node))
11221133
reportFileOnce('copy-ellipsis', null, node, 'Write the ellipsis as … in product copy')
1134+
if (ownsProductCopy) {
1135+
if (isHandConfirm(node) && !grants('CONFIRM_OWNERS', file))
1136+
report(
1137+
'confirm-owner',
1138+
location(node),
1139+
'Ask through ConfirmDialog, which owns the pending label, closes on success and shows a failure'
1140+
)
1141+
if (isHandBack(node) && file !== BACK_OWNER && !grants('BACK_OWNERS', file))
1142+
report(
1143+
'back-owner',
1144+
location(node),
1145+
'Go back through DirectoryBack on a page or DetailHeader in Settings, labelled with a bare noun'
1146+
)
1147+
const role = handRole(node)
1148+
if ((role === 'tab' || role === 'tablist') && !grants('TAB_OWNERS', file))
1149+
report(
1150+
'tab-owner',
1151+
location(node),
1152+
`Use ContentTabs, which owns role=${role} and arrow keys`
1153+
)
1154+
if (role === 'alert' && !grants('ALERT_OWNERS', file))
1155+
report(
1156+
'alert-owner',
1157+
location(node),
1158+
"Show a failed action through InlineNotice intent='danger', a failed read through FailureNotice, a field's message through its error"
1159+
)
1160+
if (isNativeTitle(node))
1161+
report(
1162+
'native-title',
1163+
location(node),
1164+
'Show the full value through Tooltip or OverflowText'
1165+
)
1166+
const titleCase = authoredData ? titleCaseWords(node) : []
1167+
if (titleCase.length)
1168+
report(
1169+
'copy-case',
1170+
location(node),
1171+
`Write ${titleCase.join(', ')} in sentence case; a product, plan or brand name goes in PROPER_NOUNS or PROPER_PHRASES (scripts/platform-source-rules.ts)`
1172+
)
1173+
}
11231174
if (
11241175
inPlatformTree(file) &&
11251176
authoredCopy &&

0 commit comments

Comments
 (0)