Skip to content

Commit d5c5ea3

Browse files
committed
feat(platform): fail helper copy lines in check:platform-design
helper-copy fails a hint on an EMCN field (or any hint that spells out copy), a description on a form Modal, a written SettingsRow or DetailHeader subtitle, and a non-empty hint in the field data the Platform renders, including the credential descriptors and catalog generator outside its tree. disabledReason, error and aria-* stay untouched, as do confirm and feedback dialog bodies and computed row state. A line that must stay is named in HELPER_COPY_OWNERS as <file>#<label, title or field id> with its reason; the design owner's untouched copy is grouped there for his review. The Platform contract, the per-job checklist and the UI copy rule say how to fold a line into the placeholder, label or option instead.
1 parent 855bbae commit d5c5ea3

9 files changed

Lines changed: 412 additions & 6 deletions

File tree

‎.claude/rules/sim-platform-views.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ like that" is not one.
1919

2020
| Job | Use | Never |
2121
| --- | --- | --- |
22+
| Explain a field, dialog or row | Its label, placeholder and options (`Access keys (long-lived)`); `error` for validation, `disabledReason` for a locked control, the dialog's Setup guide for steps | A `hint`, form `Modal` `description`, written `SettingsRow`/`DetailHeader` subtitle or data `hint` (`check:platform-design` `helper-copy`; a line that must stay is named in `HELPER_COPY_OWNERS`) |
2223
| 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 |
2324
| Failed read | `FailureNotice`, labelled Retry. `intent='warning'` where the read partly answered | A hand-built retry button or `InlineNotice` + `Button` |
2425
| Failed background refresh | `FailureNotice` above the content it keeps | Replacing content already on screen |

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ paths:
1313

1414
**Do not add subtitles, helper text, or descriptive copy beneath headings, labels, cards, or settings by default.** Prefer one concise, self-explanatory heading or label. Only add supporting copy when the user explicitly asks for it, or when it is necessary to prevent misunderstanding or error — and never use it to restate the heading.
1515

16-
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 `apps/sim/platform/AGENTS.md`; marketing surfaces (`app/(landing)`, docs) follow `constitution.md`.
16+
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

1818
**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.
1919

@@ -32,14 +32,14 @@ This applies to app UI outside the Platform: settings rows, modals, panels, card
3232
<ChipModalField title='Workspace name' />
3333
```
3434

35-
If a heading needs a subtitle to be understood, the heading is wrong. Fix the heading — don't append a second line.
35+
If a heading needs a subtitle to be understood, the heading is wrong. Fix the heading — don't append a second line. For a field, bake the meaning into the line itself first: a format into the placeholder (`us-gov-west-1`), a bound or scope into the label (`Idle timeout (hours, min 1)`), a trade-off into the option (`Access keys (long-lived)`), or point to the dialog's setup guide.
3636

3737
## When supporting copy earns its place
3838

3939
Keep (or add) a description only when it carries information the label cannot, and its absence would cause a mistake:
4040

4141
- **Irreversible or destructive consequences** — "Deleting this workspace removes every workflow and log. This cannot be undone."
42-
- **A non-obvious format, unit, or bound** — "Comma-separated. Max 50 domains.", "Cost per 1M input tokens."
42+
- **A non-obvious format, unit, or bound the label, placeholder and options cannot hold** — "Comma-separated. Max 50 domains.", "Cost per 1M input tokens."
4343
- **A security or access implication** — "This key is shown once and grants full workspace access."
4444
- **A state the user cannot otherwise see** — "Inherited from your organization's policy."
4545
- **Instructional copy that advances a flow** — "We sent a 6-digit code to you@example.com."

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ like that" is not one.
1717

1818
| Job | Use | Never |
1919
| --- | --- | --- |
20+
| Explain a field, dialog or row | Its label, placeholder and options (`Access keys (long-lived)`); `error` for validation, `disabledReason` for a locked control, the dialog's Setup guide for steps | A `hint`, form `Modal` `description`, written `SettingsRow`/`DetailHeader` subtitle or data `hint` (`check:platform-design` `helper-copy`; a line that must stay is named in `HELPER_COPY_OWNERS`) |
2021
| 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 |
2122
| Failed read | `FailureNotice`, labelled Retry. `intent='warning'` where the read partly answered | A hand-built retry button or `InlineNotice` + `Button` |
2223
| Failed background refresh | `FailureNotice` above the content it keeps | Replacing content already on screen |

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ globs: ["apps/sim/components/**/*.tsx","apps/sim/ee/**/*.tsx","apps/sim/app/_she
99

1010
**Do not add subtitles, helper text, or descriptive copy beneath headings, labels, cards, or settings by default.** Prefer one concise, self-explanatory heading or label. Only add supporting copy when the user explicitly asks for it, or when it is necessary to prevent misunderstanding or error — and never use it to restate the heading.
1111

12-
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 `apps/sim/platform/AGENTS.md`; marketing surfaces (`app/(landing)`, docs) follow `constitution.md`.
12+
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

1414
**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.
1515

@@ -28,14 +28,14 @@ This applies to app UI outside the Platform: settings rows, modals, panels, card
2828
<ChipModalField title='Workspace name' />
2929
```
3030

31-
If a heading needs a subtitle to be understood, the heading is wrong. Fix the heading — don't append a second line.
31+
If a heading needs a subtitle to be understood, the heading is wrong. Fix the heading — don't append a second line. For a field, bake the meaning into the line itself first: a format into the placeholder (`us-gov-west-1`), a bound or scope into the label (`Idle timeout (hours, min 1)`), a trade-off into the option (`Access keys (long-lived)`), or point to the dialog's setup guide.
3232

3333
## When supporting copy earns its place
3434

3535
Keep (or add) a description only when it carries information the label cannot, and its absence would cause a mistake:
3636

3737
- **Irreversible or destructive consequences** — "Deleting this workspace removes every workflow and log. This cannot be undone."
38-
- **A non-obvious format, unit, or bound** — "Comma-separated. Max 50 domains.", "Cost per 1M input tokens."
38+
- **A non-obvious format, unit, or bound the label, placeholder and options cannot hold** — "Comma-separated. Max 50 domains.", "Cost per 1M input tokens."
3939
- **A security or access implication** — "This key is shown once and grants full workspace access."
4040
- **A state the user cannot otherwise see** — "Inherited from your organization's policy."
4141
- **Instructional copy that advances a flow** — "We sent a 6-digit code to you@example.com."

‎apps/sim/platform/AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Use `ActivityRow` for a first-line glyph, avatar or artwork stack, title/detail
3939

4040
Inputs and textareas share the neutral field frame, resting border and 8px text inset. Hover changes only the frame border; the fill remains unchanged. Focus alone preserves resting paint. Required placeholders describe the expected input without replacing the label. `viewOnly` supplies native read-only behavior and an always-visible shared copy action in the sidebar’s 24px trailing slot. Textareas place the same action at the top-right. Copy feedback remains visible for four seconds before returning to the copy action. Read-only fields show a not-allowed cursor without dimming, while their copy action remains interactive. TextArea defaultSize selects regular (76px), expanded (112px) or large (240px); skill instruction editors use large. Invite recipient fields share the regular textarea height and insets. Quiet `SearchField` is the explicit focus exception: its subtle underline starts at the typing edge, excluding the leading icon and trailing controls, and appears on hover or focus while the background remains unchanged. Counts inherit adjacent label metrics, use ordinary inline spacing and align on the text baseline. Disabled dropdown triggers and items retain their resting opacity, ink, border and fill with a not-allowed cursor; disabled behavior prevents activation, and hover/press never repaints them. Optional disabledReason descriptions use the shared tooltip and remain available to keyboard users. Supply them for non-obvious restrictions such as inherited access, protected administrators or unavailable prerequisites. Button and Checkbox expose the same optional reason; DataTable forwards selectionDisabledReason from its row metadata. Reuse the actual policy reason rather than a generic disabled message, and keep the control disabled while its explanation is focused. Secret reveal and copy use the same 24px icon action and field inset; text labels belong in their accessible names and tooltips.
4141

42+
One line per control. A field, form dialog, settings row or page header carries its meaning in its label, placeholder and options, never in a second line beneath it: no field `hint`, no form `Modal` `description`, no written `SettingsRow` or `DetailHeader` subtitle, and no `hint` in the field data a dialog renders. Put a format in the placeholder (`us-gov-west-1`), a bound or scope in the label (`Idle timeout (hours, min 1)`) or an option (`Access keys (long-lived)`), point to the dialog's Setup guide, or delete the line. A validation message is an `error`; a disabled control's reason is its `disabledReason`; a preference's explanation belongs in its existing About tooltip. Only a line nothing else can hold stays (a one-time secret, an irreversible consequence, a security or privacy exposure, a state the viewer cannot otherwise see), named with that reason in `HELPER_COPY_OWNERS`; `check:platform-design` (`helper-copy`) fails any other.
43+
4244
Toolbar choices use `ChoiceMenu`, the same compact quiet button and menu as Home's activity period. A toolbar action uses the existing quiet compact Button or IconButton. Page-level actions live in the control bar beneath the title and description; an action for a particular record belongs with that record. `Select` and `SelectField` provide bordered field chrome only for labeled forms, including modal forms. Immediate preferences, row roles and toolbar filters use compact text triggers without borders or chevrons. Searchable, multiple-value and date-range pickers retain their specialized behavior with their text presentation composed from the same compact quiet Button; do not place a full-width field picker in a control bar. Reuse the existing owner for the interaction and compare its actual trigger with Home before accepting it.
4345

4446
Button-based field pickers use the owning Base UI Select.Label or Combobox.Label, through FieldFrame’s label slot, so clicking a caption focuses its control without closing and reopening the popup. A calendar popover uses a noninteractive caption explicitly associated with its trigger and displayed value. Text inputs retain native labels. Preserve selected values or counts in accessible names or descriptions alongside field hints and errors.

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1131,3 +1131,65 @@ it('fails the route and copy rules: live-only-route, fixture-branch-in-view, fix
11311131
rmSync(root, { recursive: true, force: true })
11321132
}
11331133
}, 30_000)
1134+
1135+
it('fails [helper-copy] for a hint, a form subtitle or a static row subtitle, and passes reasons, errors and confirm bodies', () => {
1136+
const { root, put, run } = productFixture()
1137+
const view = 'app/(product)/o/[organizationId]/home/_components/key-form/key-form.tsx'
1138+
try {
1139+
put(
1140+
view,
1141+
`import { SelectField, TextField } from '@sim/emcn/field'
1142+
import { Modal } from '@sim/emcn/modal'
1143+
import { SettingsRow } from '@sim/emcn/settings-section'
1144+
import { Picker } from '@/app/(product)/o/[organizationId]/home/_components/picker/picker'
1145+
export function KeyForm({ note, removing }: { note?: string; removing: boolean }) {
1146+
return <>
1147+
<Modal title='Add key' description='Keys are encrypted and stored securely.'>
1148+
<TextField label='Name' placeholder='my-key' hint='Lowercase letters only.' />
1149+
<TextField label='Region' placeholder='us-east-1' error='Enter a region.' />
1150+
<SelectField label='Plan' options={[]} disabledReason='Upgrade first.' aria-description='Billing plan' />
1151+
<Picker label='Owner' hint={note} />
1152+
<Picker label='Team' hint='Pick the owning team.' />
1153+
<SettingsRow label='Theme' description='Select your preferred color theme' />
1154+
<SettingsRow label='Status' description={removing ? 'Removing' : 'Active'} />
1155+
</Modal>
1156+
<Modal variant='confirm' title='Remove key' description='This cannot be undone.' />
1157+
</>
1158+
}
1159+
export const FIELDS = [
1160+
{ id: 'stsRegion', label: 'STS region', hint: 'Only needed for GovCloud keys.' },
1161+
{ id: 'roleArn', label: 'Role ARN', hint: '' },
1162+
]`
1163+
)
1164+
put(
1165+
'app/(product)/o/[organizationId]/home/_components/picker/picker.tsx',
1166+
`export function Picker({ label, hint }: { label: string; hint?: string }) { return <p title={hint}>{label}</p> }`
1167+
)
1168+
const result = run()
1169+
expect(result.status).toBe(1)
1170+
const helperCopy = result.output.split('\n').filter((line) => line.includes('[helper-copy]'))
1171+
for (const line of [7, 8, 12, 13, 20])
1172+
expect(
1173+
helperCopy.some((finding) => finding.startsWith(`${view}:${line} [helper-copy]`))
1174+
).toBe(true)
1175+
expect(helperCopy).toHaveLength(5)
1176+
expect(helperCopy.join('\n')).toContain('bake it into the placeholder, label or option')
1177+
expect(result.output).toContain(`${view}#Name`)
1178+
1179+
writeAllowlists(root, {
1180+
HELPER_COPY_OWNERS: {
1181+
[`${view}#Add key`]: 'The fixture keeps its dialog subtitle to prove a grant',
1182+
[`${view}#Name`]: 'The fixture keeps its field hint to prove a grant',
1183+
[`${view}#Team`]: 'The fixture keeps its wrapper hint to prove a grant',
1184+
[`${view}#Theme`]: 'The fixture keeps its row subtitle to prove a grant',
1185+
[`${view}#stsRegion`]: 'The fixture keeps its descriptor hint to prove a grant',
1186+
[`${view}#Unused`]: 'Grants nothing, so the check names it stale',
1187+
},
1188+
})
1189+
const granted = run()
1190+
expect(granted.output).not.toContain('[helper-copy]')
1191+
expect(granted.output).toContain(`HELPER_COPY_OWNERS names ${view}#Unused`)
1192+
} finally {
1193+
rmSync(root, { recursive: true, force: true })
1194+
}
1195+
}, 30_000)

‎scripts/check-platform-design.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import { ORGANIZATION_ROUTES, PRODUCT_ROUTES } from './platform-product-routes'
2020
import { ruleDocumentation } from './platform-rule-docs'
2121
import {
2222
classifyPage,
23+
type HelperCopyKind,
2324
hasAsciiEllipsis,
25+
helperCopy,
2426
inFixtureBranch,
2527
isFixtureModeBranch,
2628
isLoadingCopy,
@@ -1449,6 +1451,59 @@ for (const file of sourceFiles) {
14491451
})
14501452
}
14511453
}
1454+
/**
1455+
* Field data the Platform renders that lives outside its tree: the credential descriptors behind its
1456+
* connect dialogs, the OAuth client settings they ask for, and the generator of its integration
1457+
* catalog. A `hint` written there reaches a Platform field as surely as one written in a view.
1458+
*/
1459+
const HINT_DATA_MODULES: ReadonlySet<string> = new Set([
1460+
'lib/credentials/client-credential-accounts/descriptors.ts',
1461+
'lib/credentials/config-service-accounts/descriptors.ts',
1462+
'lib/credentials/token-service-accounts/descriptors.ts',
1463+
'lib/oauth/oauth.ts',
1464+
'../../scripts/platform-integration-connections.ts',
1465+
])
1466+
const HELPER_COPY_MESSAGES: Record<HelperCopyKind, string> = {
1467+
'field-hint': 'A field has no hint line',
1468+
'dialog-subtitle': 'A form dialog has no subtitle',
1469+
'row-subtitle': 'A settings row or page header has no written subtitle',
1470+
'data-hint': 'Field data carries no hint',
1471+
}
1472+
/**
1473+
* `helper-copy`: a field, form dialog, settings row or page header says what it needs in its own
1474+
* label, placeholder or options, never in a second line beneath. A line that must stay (a one-time
1475+
* secret warning, a consequence nothing else can show) is named in `HELPER_COPY_OWNERS` as
1476+
* `<file>#<label, title or field id>` with its reason.
1477+
*/
1478+
function checkHelperCopy(file: string) {
1479+
const content = read(file)
1480+
const source = ts.createSourceFile(
1481+
file,
1482+
content,
1483+
ts.ScriptTarget.Latest,
1484+
true,
1485+
file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
1486+
)
1487+
for (const { kind, node, subject } of helperCopy(source)) {
1488+
const entry = `${file}#${subject}`
1489+
if (grants('HELPER_COPY_OWNERS', entry)) continue
1490+
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1
1491+
report(
1492+
'helper-copy',
1493+
`${file}:${line}`,
1494+
`${HELPER_COPY_MESSAGES[kind]}: bake it into the placeholder, label or option, or delete it. A line that must stay goes in HELPER_COPY_OWNERS as ${entry} with its reason`
1495+
)
1496+
}
1497+
}
1498+
for (const file of listedFiles) {
1499+
if (
1500+
/\.tsx?$/.test(file) &&
1501+
!/\.test\.tsx?$/.test(file) &&
1502+
!file.startsWith('platform/checks/') &&
1503+
(inRouteTree(file) || file.startsWith(platform) || HINT_DATA_MODULES.has(file))
1504+
)
1505+
checkHelperCopy(file)
1506+
}
14521507
for (const [file, sheet] of sheets) checkCss(file, sheet)
14531508
for (const row of evidence) {
14541509
if (

0 commit comments

Comments
 (0)