Repository navigation
feat(platform): add the Paper design system and isolated workbench - #9667
emir-karabeg wants to merge 3 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
[Low impact] The changes stay within a new, standalone Platform workbench and repository tooling. The PR appears safe to merge, with two non-blocking workbench interaction fixes. FindingsSummaryAdds an isolated Platform workbench with shared controls, Paper recipes, theme foundations, fixture data, and design checks.
|
| <input | ||
| aria-label={searchLabel} | ||
| placeholder={searchLabel} | ||
| value={query} | ||
| data-type='T060' | ||
| onChange={(event) => setQuery(event.target.value)} |
There was a problem hiding this comment.
Pressing Enter in the SearchableSelect search box submits the surrounding dialog form. In the workbench’s “Default role” step, this calls advance() without choosing the matching role. The previous role stays selected. Keep Enter inside the picker while it is open rather than letting it submit the form.
| .viewport { | ||
| max-block-size: var(--spacing-154); | ||
| min-block-size: 0; | ||
| overflow: hidden; |
There was a problem hiding this comment.
Catalog rows cannot be reached
This wrapper has only max-block-size, so the nested ScrollArea cannot resolve its percentage height against a definite parent height. It grows with its content, and the wrapper hides everything beyond the limit. For example, C1082 contains the twenty-row C1080 list, but its later rows cannot be scrolled into view. Give the wrapper a definite block-size, as the workbench chat list already does.
There was a problem hiding this comment.
18 issues found across 91 files
Confidence score: 3/5
scripts/sync-platform-recipes.tsemits numeric style values as unitless lengths, so browsers discard generated positions such asleftandtop. Add units for dimensional properties while preserving unitless values where appropriate.specimen.module.cssgives.viewportonly a maximum height, so the nested ScrollArea may never get a definite height or overflow. Give the scroll region a definite height.searchable-select.tsxkeeps the selected value local and does not pass the typed query toonCreate, leaving consumers unable to read the selection or create the requested item. Expose the value and pass the query to the create callback.workspace-fields.tsxdoes not retain or report typed addresses, so they can be lost instead of reaching the caller. Add a change callback or expose the value through a form field.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/platform/design-system/components/range-picker.tsx">
<violation number="1" location="apps/sim/platform/design-system/components/range-picker.tsx:24">
P2: `value` is initialized from `options[0]` only once, but the trigger's `disabled` state tracks `options` live. If options arrive after mount, the control enables with no selected value and never falls back to `options[0]`. Derive the displayed value from the current options instead of freezing it in `useState`.</violation>
<violation number="2" location="apps/sim/platform/design-system/components/range-picker.tsx:33">
P3: A disabled picker is visually indistinguishable from an enabled one and still shows a pointer cursor, because `.trigger` has no `:disabled` rule. Add a `.trigger:disabled` rule in `range-picker.module.css` with `cursor: default` and `opacity: var(--opacity-disabled)`, matching the other disabled controls.</violation>
<violation number="3" location="apps/sim/platform/design-system/components/range-picker.tsx:64">
P3: Re-selecting the current option still calls `onValueChange`, so consumers get a change event with no change. Guard the state update and callback with `option !== value`, and still close the menu.</violation>
</file>
<file name="scripts/sync-platform-foundations.ts">
<violation number="1" location="scripts/sync-platform-foundations.ts:36">
P2: Any fourth argument other than `--check` silently runs the write path. Reject unknown modes so a mistyped `--check` cannot regenerate the files and report success.</violation>
<violation number="2" location="scripts/sync-platform-foundations.ts:52">
P2: The `>= 4` cutoff keeps 1–3px spacing and radius tokens in px, so they will not scale with the root font size while the rest of the scale does. The header only justifies keeping strokes in px. Scope the exemption to stroke types, or convert every scalable px value.</violation>
<violation number="3" location="scripts/sync-platform-foundations.ts:65">
P3: `tokens.css` is written before the type recipes are validated. If a type style is missing, the run throws after updating only `tokens.css`, leaving the two generated files out of sync. Build all outputs in memory, then write them together.</violation>
</file>
<file name="scripts/sync-platform-recipes.ts">
<violation number="1" location="scripts/sync-platform-recipes.ts:212">
P2: Numeric style literals are emitted as unitless CSS lengths, so browsers discard positions such as the generated `left: 16` and `top: 6`. Normalize numeric values with units for dimensional properties while preserving unitless properties and zero.</violation>
</file>
<file name="scripts/platform-variant-usage.ts">
<violation number="1" location="scripts/platform-variant-usage.ts:86">
P2: A local `const` holding JSX is walked only once, under the first prop signature that reaches it. The `seen.has(decl)` guard in the identifier branch blocks re-walks with different props, so variants passed through that local can be missed. Key the guard by the props signature as well as the declaration, or walk the initializer on every reference.</violation>
</file>
<file name="apps/sim/platform/preview/specimen.module.css">
<violation number="1" location="apps/sim/platform/preview/specimen.module.css:24">
P2: `.viewport` caps the height with `max-block-size`, which does not give the nested ScrollArea a definite height. Its `max-block-size: 100%` resolves to none, so the scroll region never overflows and the `overflow: hidden` here clips the remaining content with no way to reach it. Use `block-size` so the child percentage resolves against a definite height.</violation>
</file>
<file name="apps/sim/platform/design-system/styles/control-roles.css">
<violation number="1" location="apps/sim/platform/design-system/styles/control-roles.css:8">
P3: In light theme, `--control-danger-hover` and `--control-danger-pressed` both resolve to the same `--color-light-danger` as the resting `--control-danger`, so destructive buttons show no hover or pressed feedback. Map these to a distinct light-theme red shade, as the dark block already does for hover and pressed. Primary pressed in light has the same problem and should get a distinct value too.</violation>
</file>
<file name="apps/sim/platform/design-system/components/search-field.tsx">
<violation number="1" location="apps/sim/platform/design-system/components/search-field.tsx:34">
P2: The shared `event.shiftKey` guard also blocks `/`, even though `event.key === '/'` already pins the character. On layouts that type `/` with Shift (for example German or Spanish), the shortcut never fires. Limit the Shift exclusion to the `⌘K` branch.</violation>
</file>
<file name="apps/sim/platform/design-system/components/fields.tsx">
<violation number="1" location="apps/sim/platform/design-system/components/fields.tsx:71">
P2: Optional fields lose the "(optional)" marker for assistive tech. The `aria-label={label}` set by `Input`, `TextArea` and `Select` overrides the `<label>` text, so the marker is dropped from the control's name. Pass the optional-aware label to the control in `TextField`, `TextAreaField` and `SelectField`.</violation>
</file>
<file name="apps/sim/platform/preview/workbench.tsx">
<violation number="1" location="apps/sim/platform/preview/workbench.tsx:32">
P3: The theme and fixture-state links drop `destination`, so toggling theme or state resets a selected workspace section to Dashboard. `href()` carries `recipe` explicitly but not `destination`, while the specimen's tab links keep it. Carry `destination` through the same helper.</violation>
</file>
<file name="apps/sim/platform/design-system/components/searchable-select.tsx">
<violation number="1" location="apps/sim/platform/design-system/components/searchable-select.tsx:75">
P2: This selection list has no arrow-key navigation and uses toggle semantics (`aria-pressed`). Keyboard users must Tab through every option, and screen readers announce toggle buttons instead of a single-select list. Reuse the `ActionMenu`/`MenuItem` keyboard and radio pattern, or add equivalent roving focus and arrow handling.</violation>
<violation number="2" location="apps/sim/platform/design-system/components/searchable-select.tsx:76">
P2: Selecting an option updates only local state; there is no callback or form control for consumers to read the value. Expose an `onValueChange` callback or a named form value.</violation>
<violation number="3" location="apps/sim/platform/design-system/components/searchable-select.tsx:90">
P2: The create action cannot tell the consumer what to create. `onCreate` takes no arguments and the typed search text is never passed, so a creatable select loses the user's input. Pass the current query to `onCreate` and type it as `(value: string) => void`.</violation>
</file>
<file name="apps/sim/platform/design-system/components/controls.tsx">
<violation number="1" location="apps/sim/platform/design-system/components/controls.tsx:182">
P2: `AvatarGroup` already renders the first two people, so this overflow popover repeats them instead of listing only hidden members; pass `people.slice(visibleCount)` to `PeoplePopover`.</violation>
</file>
<file name="apps/sim/platform/design-system/components/workspace-fields.tsx">
<violation number="1" location="apps/sim/platform/design-system/components/workspace-fields.tsx:90">
P2: Typing an address never updates `recipients` or reaches the caller, and this input has no `name` for form submission. Add an add/change callback or expose a form value so entered addresses are retained.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Turn on auto-fix | Re-trigger cubic
| size = 'regular', | ||
| onValueChange, | ||
| }: RangePickerProps) { | ||
| const [value, setValue] = useState(defaultValue ?? options[0]) |
There was a problem hiding this comment.
P2: value is initialized from options[0] only once, but the trigger's disabled state tracks options live. If options arrive after mount, the control enables with no selected value and never falls back to options[0]. Derive the displayed value from the current options instead of freezing it in useState.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/platform/design-system/components/range-picker.tsx, line 24:
<comment>`value` is initialized from `options[0]` only once, but the trigger's `disabled` state tracks `options` live. If options arrive after mount, the control enables with no selected value and never falls back to `options[0]`. Derive the displayed value from the current options instead of freezing it in `useState`.</comment>
<file context>
@@ -0,0 +1,75 @@
+ size = 'regular',
+ onValueChange,
+}: RangePickerProps) {
+ const [value, setValue] = useState(defaultValue ?? options[0])
+ const [open, setOpen] = useState(false)
+ return (
</file context>
| const scalable = new Set(['fontSize', 'lineHeight', 'container', 'spacing', 'breakpoint', 'radius']) | ||
| function tokenValue(token: Token) { | ||
| const size = /^(\d+(?:\.\d+)?)px$/.exec(token.value) | ||
| return size && scalable.has(token.type) && Number(size[1]) >= 4 |
There was a problem hiding this comment.
P2: The >= 4 cutoff keeps 1–3px spacing and radius tokens in px, so they will not scale with the root font size while the rest of the scale does. The header only justifies keeping strokes in px. Scope the exemption to stroke types, or convert every scalable px value.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/sync-platform-foundations.ts, line 52:
<comment>The `>= 4` cutoff keeps 1–3px spacing and radius tokens in px, so they will not scale with the root font size while the rest of the scale does. The header only justifies keeping strokes in px. Scope the exemption to stroke types, or convert every scalable px value.</comment>
<file context>
@@ -0,0 +1,101 @@
+const scalable = new Set(['fontSize', 'lineHeight', 'container', 'spacing', 'breakpoint', 'radius'])
+function tokenValue(token: Token) {
+ const size = /^(\d+(?:\.\d+)?)px$/.exec(token.value)
+ return size && scalable.has(token.type) && Number(size[1]) >= 4
+ ? `${Number(size[1]) / 16}rem`
+ : token.value
</file context>
| const { typography }: { typography: TypeMaster[] } = JSON.parse(readFileSync(registryFile, 'utf8')) | ||
| const styles: Record<string, TypeStyle> = JSON.parse(readFileSync(stylesFile, 'utf8')) | ||
| const directory = path.resolve(import.meta.dirname, '../apps/sim/platform/design-system/styles') | ||
| const compare = mode === '--check' |
There was a problem hiding this comment.
P2: Any fourth argument other than --check silently runs the write path. Reject unknown modes so a mistyped --check cannot regenerate the files and report success.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/sync-platform-foundations.ts, line 36:
<comment>Any fourth argument other than `--check` silently runs the write path. Reject unknown modes so a mistyped `--check` cannot regenerate the files and report success.</comment>
<file context>
@@ -0,0 +1,101 @@
+const { typography }: { typography: TypeMaster[] } = JSON.parse(readFileSync(registryFile, 'utf8'))
+const styles: Record<string, TypeStyle> = JSON.parse(readFileSync(stylesFile, 'utf8'))
+const directory = path.resolve(import.meta.dirname, '../apps/sim/platform/design-system/styles')
+const compare = mode === '--check'
+function emit(filename: string, css: string) {
+ const file = path.join(directory, filename)
</file context>
| if (!ts.isPropertyAssignment(prop)) continue | ||
| const value = prop.initializer | ||
| if (ts.isStringLiteral(value) || ts.isNumericLiteral(value)) | ||
| style[prop.name.getText(file)] = value.text |
There was a problem hiding this comment.
P2: Numeric style literals are emitted as unitless CSS lengths, so browsers discard positions such as the generated left: 16 and top: 6. Normalize numeric values with units for dimensional properties while preserving unitless properties and zero.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/sync-platform-recipes.ts, line 212:
<comment>Numeric style literals are emitted as unitless CSS lengths, so browsers discard positions such as the generated `left: 16` and `top: 6`. Normalize numeric values with units for dimensional properties while preserving unitless properties and zero.</comment>
<file context>
@@ -0,0 +1,630 @@
+ if (!ts.isPropertyAssignment(prop)) continue
+ const value = prop.initializer
+ if (ts.isStringLiteral(value) || ts.isNumericLiteral(value))
+ style[prop.name.getText(file)] = value.text
+ }
+ }
</file context>
| } | ||
| } | ||
| function visit(node: ts.Node) { | ||
| if (ts.isFunctionDeclaration(node) || ts.isVariableDeclaration(node)) return |
There was a problem hiding this comment.
P2: A local const holding JSX is walked only once, under the first prop signature that reaches it. The seen.has(decl) guard in the identifier branch blocks re-walks with different props, so variants passed through that local can be missed. Key the guard by the props signature as well as the declaration, or walk the initializer on every reference.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/platform-variant-usage.ts, line 86:
<comment>A local `const` holding JSX is walked only once, under the first prop signature that reaches it. The `seen.has(decl)` guard in the identifier branch blocks re-walks with different props, so variants passed through that local can be missed. Key the guard by the props signature as well as the declaration, or walk the initializer on every reference.</comment>
<file context>
@@ -0,0 +1,187 @@
+ }
+ }
+ function visit(node: ts.Node) {
+ if (ts.isFunctionDeclaration(node) || ts.isVariableDeclaration(node)) return
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
+ const decl = declaration(node.tagName)
</file context>
| value={option} | ||
| selected={option === value} | ||
| onSelect={() => { | ||
| setValue(option) |
There was a problem hiding this comment.
P3: Re-selecting the current option still calls onValueChange, so consumers get a change event with no change. Guard the state update and callback with option !== value, and still close the menu.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/platform/design-system/components/range-picker.tsx, line 64:
<comment>Re-selecting the current option still calls `onValueChange`, so consumers get a change event with no change. Guard the state update and callback with `option !== value`, and still close the menu.</comment>
<file context>
@@ -0,0 +1,75 @@
+ value={option}
+ selected={option === value}
+ onSelect={() => {
+ setValue(option)
+ setOpen(false)
+ onValueChange?.(option)
</file context>
| type='button' | ||
| className={styles.trigger} | ||
| aria-label={value ? `${label}: ${value}` : label} | ||
| disabled={!options.length} |
There was a problem hiding this comment.
P3: A disabled picker is visually indistinguishable from an enabled one and still shows a pointer cursor, because .trigger has no :disabled rule. Add a .trigger:disabled rule in range-picker.module.css with cursor: default and opacity: var(--opacity-disabled), matching the other disabled controls.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/platform/design-system/components/range-picker.tsx, line 33:
<comment>A disabled picker is visually indistinguishable from an enabled one and still shows a pointer cursor, because `.trigger` has no `:disabled` rule. Add a `.trigger:disabled` rule in `range-picker.module.css` with `cursor: default` and `opacity: var(--opacity-disabled)`, matching the other disabled controls.</comment>
<file context>
@@ -0,0 +1,75 @@
+ type='button'
+ className={styles.trigger}
+ aria-label={value ? `${label}: ${value}` : label}
+ disabled={!options.length}
+ data-size={size}
+ data-type='T060'
</file context>
| `/* Shared foundation. Reference px become rem at a 16px root; strokes retain their measured width. */\n[data-platform-ui] {\n${ordered.map((t) => ` ${t.name}: ${tokenValue(t)};`).join('\n')}\n}` | ||
| ) | ||
|
|
||
| const recipes = new Map<string, string[]>() |
There was a problem hiding this comment.
P3: tokens.css is written before the type recipes are validated. If a type style is missing, the run throws after updating only tokens.css, leaving the two generated files out of sync. Build all outputs in memory, then write them together.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/sync-platform-foundations.ts, line 65:
<comment>`tokens.css` is written before the type recipes are validated. If a type style is missing, the run throws after updating only `tokens.css`, leaving the two generated files out of sync. Build all outputs in memory, then write them together.</comment>
<file context>
@@ -0,0 +1,101 @@
+ `/* Shared foundation. Reference px become rem at a 16px root; strokes retain their measured width. */\n[data-platform-ui] {\n${ordered.map((t) => ` ${t.name}: ${tokenValue(t)};`).join('\n')}\n}`
+)
+
+const recipes = new Map<string, string[]>()
+for (const { key, base } of typography) {
+ const type = styles[base]
</file context>
| --control-primary-pressed: var(--color-light-text-strong); | ||
| --control-danger: var(--color-light-danger); | ||
| --control-danger-soft: var(--color-red-soft); | ||
| --control-danger-hover: var(--color-light-danger); |
There was a problem hiding this comment.
P3: In light theme, --control-danger-hover and --control-danger-pressed both resolve to the same --color-light-danger as the resting --control-danger, so destructive buttons show no hover or pressed feedback. Map these to a distinct light-theme red shade, as the dark block already does for hover and pressed. Primary pressed in light has the same problem and should get a distinct value too.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/platform/design-system/styles/control-roles.css, line 8:
<comment>In light theme, `--control-danger-hover` and `--control-danger-pressed` both resolve to the same `--color-light-danger` as the resting `--control-danger`, so destructive buttons show no hover or pressed feedback. Map these to a distinct light-theme red shade, as the dark block already does for hover and pressed. Primary pressed in light has the same problem and should get a distinct value too.</comment>
<file context>
@@ -0,0 +1,45 @@
+ --control-primary-pressed: var(--color-light-text-strong);
+ --control-danger: var(--color-light-danger);
+ --control-danger-soft: var(--color-red-soft);
+ --control-danger-hover: var(--color-light-danger);
+ --control-danger-pressed: var(--color-light-danger);
+ --control-hover-border: var(--color-ink-20);
</file context>
| const workspace = organization?.workspaces[0] | ||
| const href = (nextTheme: string, nextState: string) => { | ||
| const params = new URLSearchParams({ theme: nextTheme, state: nextState }) | ||
| if (recipe) params.set('recipe', recipe) |
There was a problem hiding this comment.
P3: The theme and fixture-state links drop destination, so toggling theme or state resets a selected workspace section to Dashboard. href() carries recipe explicitly but not destination, while the specimen's tab links keep it. Carry destination through the same helper.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/platform/preview/workbench.tsx, line 32:
<comment>The theme and fixture-state links drop `destination`, so toggling theme or state resets a selected workspace section to Dashboard. `href()` carries `recipe` explicitly but not `destination`, while the specimen's tab links keep it. Carry `destination` through the same helper.</comment>
<file context>
@@ -0,0 +1,221 @@
+ const workspace = organization?.workspaces[0]
+ const href = (nextTheme: string, nextState: string) => {
+ const params = new URLSearchParams({ theme: nextTheme, state: nextState })
+ if (recipe) params.set('recipe', recipe)
+ return `/?${params}`
+ }
</file context>
Adds the new Platform design system and a standalone Next.js workbench under
apps/sim/platform. The library covers 608 Paper masters with evidence of a Platform use. It pairs native interactive controls with typed structural recipes and centralizes typography, color, materials, icons and responsive sizing.The workbench supports light and dark themes, empty organizations, populated workspaces and long content. Repeated rows, attachment batches and plots take typed records. Chart marks, labels, totals and tooltips follow the same data even when its length differs from the Paper specimen. Sample data loads only with
SIM_PLATFORM_FIXTURES=1. RunSIM_PLATFORM_FIXTURES=1 bun run --cwd apps/sim dev:platformand open port 3100.The implementation skill and scoped agent instructions define how to promote a feature component, retire a variant and verify a change against Paper. CI checks ownership, styling boundaries, token references and supported variants, including unused variants behind unreachable branches and inherited style props. Browser tests exercise keyboard interaction, selection, nested popovers and dialogs, fixture isolation, variable-length collections and charts, scrolling, type scaling and responsive layouts.
Validation includes the production build, Playwright, repository lint, TypeScript, the audit suite and the full repository test suite. The 20-case production suite also requests all 608 catalog selections through the workbench. Dashboard table coverage includes number formatting, zero versus missing values and six-, four- and three-column layouts.