This is the canonical conventions file for AI coding agents working in this repository. Tool-specific configs (.claude/CLAUDE.md, .github/copilot-instructions.md, .opencode/) reference this file rather than duplicating its content.
Equinor Design System (EDS) is a pnpm monorepo containing React component libraries and design tokens. New components are developed in /next (packages/eds-core-react/src/components/next/).
@equinor/eds-core-react— Main React component library@equinor/eds-core-react/next— New EDS 2.0 components (active development)@equinor/eds-tokens— Design tokens, CSS variables, and theming@equinor/eds-icons— Icon library
Before scaffolding a new component, check documentation/AI-COMPONENT-INDEX.md — a generated list of every /next component with its props and sub-components. It is regenerated on pnpm run build (or run pnpm run generate:component-index ad-hoc). Don't edit it by hand.
Never read, search, copy, or print the contents of secret files. The rule applies to every harness (Claude Code, Copilot, OpenCode) regardless of whether the harness enforces it.
Treat these patterns as off-limits:
.env,.env.*(real.envlives atpackages/eds-tokens-sync/bin/.env— seepackages/eds-tokens-sync/CLAUDE.md)id_rsa*,*.pem,*.keycredentials.json,secrets.jsonsecrets/**,config/credentials.json
If you need to verify a secret file's shape, report length + first/last few characters only — never the body. If a tool blocks access (e.g. Claude Code's read_hook.js), do not try to work around it; the block is the intended behaviour.
Enforcement matrix:
| Harness | Enforcement |
|---|---|
| Claude Code | Hard-enforced via .claude/settings.json permissions.deny + .claude/hooks/read_hook.js |
| Copilot CLI | Hard-enforced via .github/hooks/block-secrets.json + .github/hooks/block-secrets.js |
| Copilot in IDE | Agent-respected only — IDE Copilot does not run the CLI hook; follow this rule manually |
| OpenCode | Agent-respected only — permission.bash covers commands, not file reads |
When an agent edits a file, the result must end up formatted and lint-fixed, regardless of which harness made the edit. Otherwise the same change lands as a clean diff in one harness and a noisy one in another.
The expected behaviour after any edit to a .ts, .tsx, or /components/next/**/*.css file:
- ESLint
--fixruns on.ts/.tsx - Stylelint
--fixruns on.cssfiles insidepackages/eds-core-react/src/components/next/ - Prettier formatting is applied (covered by Prettier itself via VS Code
editor.formatOnSaveor by the lint --fix passes)
Enforcement matrix:
| Harness | Enforcement |
|---|---|
| Claude Code | .claude/hooks/format_hook.js runs eslint/stylelint --fix after every Edit/Write |
| Copilot CLI | .github/hooks/format-on-edit.{json,js} runs the same eslint/stylelint --fix as a postToolUse hook |
| Copilot in IDE | .vscode/settings.json editor.formatOnSave: true (Prettier) — does NOT run eslint/stylelint auto-fix |
| OpenCode | No enforced hook — run pnpm run lint <file> manually after edits, or configure an equivalent post-hook |
If you edit code in a harness without enforced auto-fix, run pnpm run lint <file> before considering the change done.
Package manager: pnpm@10.15.0
pnpm run build # Build all packages
pnpm run build:core-react # Build eds-core-react only
pnpm run lint:all # Lint entire codebase
pnpm run lint ./path/to/file.tsx # Lint specific file
pnpm run test:core-react # Run eds-core-react tests
pnpm run test:watch:core-react # Watch mode
# Run a single test file (from package directory)
cd packages/eds-core-react
pnpm test -- --testPathPattern="Icon"
pnpm run storybook # Start StorybookNew components go in packages/eds-core-react/src/components/next/:
ComponentName/
index.ts # Named exports only
ComponentName.tsx # Main component with forwardRef
ComponentName.types.ts # TypeScript types with JSDoc
componentname.css # Vanilla CSS with design tokens
ComponentName.figma.tsx # Figma Code Connect (when a Figma design exists)
ComponentName.test.tsx # Jest + Testing Library + jest-axe
ComponentName.stories.tsx
For the per-component patterns that don't fit in this overview — foundation data-* attribute values, the data-color-appearance smallest-element rule, data-space-proportions calculation, disabled-state tokens, data-baseline for exact height, common mistakes, advanced patterns, and the anti-patterns checklist — see documentation/agent-instructions/BUILDING_EDS_2_COMPONENTS.md. Harness scaffolding commands (/new-component in Claude Code, the new-component prompt in Copilot, the eds-component sub-agent in OpenCode) all reference that file.
- 2 spaces, no semicolons, single quotes, trailing commas, LF line endings
// 1. React
import { forwardRef, useId } from 'react'
// 2. Types (use `import type`)
import type { ComponentProps } from './Component.types'
// 3. Styles last
import './component.css'No default exports (except Storybook files). Always use named exports.
export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
export type IconProps = {
/** Icon data from @equinor/eds-icons */
data: IconData
/** Title for accessibility - makes icon semantic with role="img" */
title?: string
/** Explicit size override */
size?: IconSize
} & Omit<SVGProps<SVGSVGElement>, 'color'>export const Icon = forwardRef<SVGSVGElement, IconProps>(function Icon(
{ data, title, color = 'currentColor', size, className, ...rest },
ref,
) {
const titleId = useId()
if (!data) {
console.error('Icon: data prop is required')
return null
}
const classes = ['icon', className].filter(Boolean).join(' ')
return (
<svg ref={ref} className={classes} data-icon-size={size} {...rest}>
{title && <title id={titleId}>{title}</title>}
<path d={data.svgPathData} />
</svg>
)
})
Icon.displayName = 'Icon'Set Component.displayName after every forwardRef definition so React DevTools and snapshot output show the component name (sub-components use dotted names, e.g. Field.Label).
Place helper functions and constants at module scope, not inside the component body — they should not be recreated on every render.
Vanilla CSS only — no CSS-in-JS (styled-components, emotion), no Tailwind utility classes. One .css file per component (single responsibility), with an eds--prefixed root class. Internal elements use simple class names scoped by CSS nesting. Variants and state use data attributes.
For the underlying token system — colour categories, static vs dynamic tokens, typography — see the package-local instruction files (frontmatter applyTo: '**' for Copilot users who configure their instruction-file location to include this path; Claude / OpenCode users read these on demand):
packages/eds-tokens/instructions/colors.md— colour system overviewpackages/eds-tokens/instructions/colors-static.md— static semantic + concept tokenspackages/eds-tokens/instructions/colors-dynamic.md— dynamic appearance tokens (thedata-color-appearancepattern)packages/eds-tokens/instructions/typography.md— typography tokens, modes, baseline trimmingdocumentation/how-to/TOKEN_SYSTEM_GUIDE.md— end-to-end token lifecycle (Figma → JSON → CSS bundle)
@layer eds-components {
.eds-icon {
font-size: var(--eds-typography-icon-size, 1.5em);
width: 1em;
height: 1em;
flex-shrink: 0;
&[data-icon-size='lg'] {
--_explicit-size: var(--eds-sizing-icon-lg);
width: var(--_explicit-size);
height: var(--_explicit-size);
}
}
}Prefer dynamic tokens (e.g. --eds-selectable-space-vertical, --eds-typography-icon-size) over hard-coded values. They adapt to density, typography, and accessibility settings without per-component overrides.
Set font-family, font-size, and line-height directly in the component CSS using the per-role semantic typography tokens — do not add data-font-family / data-font-size / data-line-height attributes to the component's own elements:
.eds-button {
font-family: var(--eds-typography-ui-body-font-family);
font-size: var(--eds-typography-ui-body-md-font-size);
line-height: var(--eds-typography-ui-body-md-line-height-squished);
}Token shape: --eds-typography-{ui-body|header}-{xs..6xl}-{font-size,line-height-default,line-height-squished,font-weight-{lighter,normal,bolder}}. font-family is set once per role (--eds-typography-{ui-body,header}-font-family).
The data-font-* runtime-switching pattern still exists for elements.css defaults and ad-hoc consumer markup, but inside a component's own CSS the size and role are part of the design and should be expressed as tokens. See packages/eds-tokens/instructions/typography.md for both paths.
Define component-scoped variables with a --_ prefix at the component root. Use these variables for all properties. In variants and states, override only the variable — never the property directly. The pattern was introduced for typography inheritance (see documentation/adr/0005-typography-approach-for-eds-2.md) and is now applied broadly across components.
/* CORRECT */
.eds-button {
--_color: var(--eds-color-text-strong-on-emphasis);
--_bg-color: var(--eds-color-bg-fill-emphasis-default);
color: var(--_color);
background-color: var(--_bg-color);
}
.eds-button[data-variant='ghost']:disabled {
--_color: var(--eds-color-text-disabled); /* override the variable */
}
/* WRONG — never override the property directly */
.eds-button[data-variant='ghost']:disabled {
color: var(--eds-color-text-disabled);
}Wrap all component styles in @layer eds-components { }. Rules outside the layer (e.g. display overrides) must be placed after the layer block with a comment explaining why they are outside.
Use data-* attributes for all variants, sizes, and boolean states — not modifier classes.
.eds-button[data-variant='primary'] {
}
.eds-button[data-selectable-space='lg'] {
}
.eds-button[data-icon-only] {
}
.eds-button[data-round] {
}
.eds-button[data-multiline] {
}Density variants are applied by setting data-density on an ancestor element. Component CSS selects against this ancestor. See documentation/adr/0004-component-conventions-for-eds-2.md and documentation/adr/0004-spacing-approach-for-eds-2.md for the rationale.
[data-density='comfortable'] .eds-button[data-selectable-space='md'] {
--_min-height: 1.5rem;
}Font sizes follow a mathematical scale based on a --_base value:
:root,
[data-density='spacious'] {
--_base: 16px;
--font-size-md: round(calc(var(--_base) * pow(2, -1/5)), 0.5px);
}
[data-density='comfortable'] {
--_base: 14px; /* only the base changes; all derived values update automatically */
}Use @supports to layer in advanced CSS features. The base styles work everywhere; the @supports block adds what only supported browsers can handle:
/* Base — all browsers: symmetric padding keeps text vertically centred */
padding-block: var(--eds-selectable-space-vertical);
/* Enhancement — trims whitespace above/below the cap-height */
@supports (text-box: trim-both ex alphabetic) {
padding-top: var(--padding-top-baseline);
padding-bottom: 0;
text-box: trim-both ex alphabetic;
}CSS @function (Chrome/Edge 128+) is a future enhancement — define it and comment it in once Safari ships support.
Jest + Testing Library. Organize tests by category with describe blocks:
import { render, screen } from '@testing-library/react'
import { axe } from 'jest-axe'
import { Icon } from '.'
describe('Icon (next)', () => {
describe('Rendering', () => {
it('renders with data prop', () => {
render(<Icon data={save} />)
expect(screen.getByTestId('eds-icon')).toBeInTheDocument()
})
})
describe('Accessibility', () => {
it('is decorative (aria-hidden) when no title', () => {
render(<Icon data={save} />)
expect(screen.getByTestId('eds-icon')).toHaveAttribute('aria-hidden', 'true')
})
it('passes axe accessibility test', async () => {
const { container } = render(<Icon data={save} title="Save" />)
expect(await axe(container)).toHaveNoViolations()
})
})
})Query priority: getByRole > getByLabelText > getByText > getByTestId
- Components/Types: PascalCase (
Button,ButtonProps) - Variables/Functions: camelCase (
isDisabled,useToken) - CSS classes:
eds-prefix on root class (eds-button,eds-text-area); simple nested names for internal elements (.label-row,.icon); variants via data attributes - Files: Match export (
Icon.tsx,Icon.types.ts,icon.css)
EDS 2.0 uses the asChild pattern for components that need polymorphic rendering (e.g. Link, Button). This lets consumers swap the underlying element for router links, custom components, etc. See documentation/adr/0005-use-aschild-slot-for-polymorphism.md for the rationale.
Slotutility inpackages/eds-core-react/src/components/next/Slot/merges parent props onto the child element- Add
asChild?: booleanto the component's props type - When
asChildis true, render<Slot>instead of the default element - Extract shared props into a
sharedPropsobject to avoid duplication - See
Slot/README.mdfor merge behavior details and usage examples
Components that should support asChild: Link, Button, and any component rendering an interactive element that consumers may want to swap.
When implementing a component from a Figma design:
- Start fresh — implement only what the design specifies. Do not borrow props or patterns from similar components without verifying them in Figma.
- Use exact variable names from the design. Never assume tokens based on semantics ("looks like accent blue →
--eds-color-accent-*"). Verify the actual variable name. - Never hardcode hex values — always use
--eds-*CSS variables from@equinor/eds-tokens. - Check every state — Default, Hover, Focus, Disabled, Error. Tokens often change between states (especially
data-color-appearanceon disabled icons).
Figma layers prefixed with ⌘, ., or ↳ represent nested sub-components. In React, model them as composable sub-components or map their props with Code Connect's figma.nestedProps():
figma.connect(Component, 'figma-url', {
props: {
disabled: figma.enum('State', { Disabled: true }),
inner: figma.nestedProps('⌘ InnerComponent', {
open: figma.enum('Open', { true: true }),
}),
},
example: ({ disabled, inner }) => (
<Component disabled={disabled} open={inner.open} />
),
})- Combining Figma design with patterns from similar existing components
- Adding props or features not in the design
- Importing from other components without explicit design requirement
- Ignoring
⌘/./↳prefixed layers
When the Figma MCP server is available (Claude Code, OpenCode):
- Analyze structure:
figma_get_design_contextwith the Figma URL to understand the component layers. - See the design:
figma_get_screenshotfor visual reference. - Extract tokens for EACH state:
figma_get_variable_defsfor Default, Hover, Focus, Disabled, Error (and any other states visible in the design).
Do not skip step 3 per state. A single figma_get_variable_defs call on the default state will miss state-specific tokens (especially data-color-appearance on disabled icons).
- Calling
figma_get_design_contextalone withoutfigma_get_variable_defsfor each state - Reusing tokens extracted for a different state
- Generating Code Connect from intuition instead of from the actual MCP response
In Claude Code, .claude/rules/figma-component.md loads this section automatically when editing *.figma.tsx.
- WCAG 2.1 AA compliance required
- Decorative elements:
aria-hidden="true" - Semantic elements:
role="img"witharia-labelledby - Proper ARIA attributes (roles, labels, states) and keyboard support (Tab, Enter, Escape, Arrow keys where relevant)
- Manage focus correctly: trap focus in modals/dialogs, restore focus on close, use
:focus-visiblefor keyboard-only outlines - Test with
jest-axein every component
For running an accessibility audit on a deployed page or Storybook story, see documentation/agent-instructions/ACCESSIBILITY_AUDIT.md — checklist, output format, and severity rubric. Harness entry points (/accessibility-audit in Claude Code, the accessibility-audit prompt in Copilot, the accessibility-audit agent in OpenCode) all reference that doc.
Component docs live in apps/design-system-docs/docs/components/{category}/{component}.md. For writing or reviewing them — tone of voice, formatting (British English, no em-dashes, admonitions), section order, output template, Storybook iframes workflow, sidebar registration, verification checklist — see documentation/agent-instructions/COMPONENT_DOC_STYLE.md. Harness entry points (/create-component-doc in Claude Code, the structure_components_prompt / verify_components_prompt in Copilot, the component-doc agent in OpenCode) all reference that doc.
EDS is adopting the Tokens Studio platform as the source for a new token pipeline, replacing the legacy Figma-REST sync over time. For platform concepts (organizations, projects, branches, releases), studio CLI setup and commands, the .studio.json configuration model, the safety rubric for CLI commands, and how to verify against live sources instead of answering from memory, see documentation/agent-instructions/TOKENS_STUDIO.md. Harness entry points (/tokens-studio in Claude Code, the tokens-studio prompt in Copilot, the tokens-studio agent in OpenCode) all reference that doc. The legacy pipeline remains documented in documentation/how-to/TOKEN_SYSTEM_GUIDE.md.
type: description
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
Breaking: feat!: remove deprecated prop
Scope is optional and usually omitted in this repo. Most monorepos using release-please do use scopes — this repo is a deliberate exception because of the exclude-paths configuration in release-please-config.json. Storybook, tests, README, config, and other non-publishable files are excluded from triggering releases based on file path. Adding a package scope to a visible type (feat, fix) bypasses that exclusion and forces a bump regardless of which files changed. Hidden types (chore, build, ci, docs, test) don't trigger releases either way, so a scope on those is harmless. Default to no scope unless one of the exceptions below applies.
When to add a scope:
- The commit touches a single package and the file paths don't make that obvious from the diff (rare — usually the path makes it clear).
- It's an infrastructure scope (
config,github,build,deps,docs,devcontainer) for changes that don't belong to any package.
Available scopes (when needed):
- Packages:
eds-core-react,eds-data-grid-react,eds-icons,eds-lab-react,eds-tailwind,eds-tokens,eds-tokens-build,eds-tokens-sync,eds-utils,design-system-docs,eds-color-palette-generator,eds-demo,figma-broker - Infrastructure:
config,github,build,deps,docs,devcontainer
For non-publishable changes (config, Storybook, tests, README, docs), use hidden types: chore, build, ci, docs, or test.
PR titles must also follow the conventional commits format — they appear in changelogs and merge history.
See documentation/how-to/CONVENTIONAL_COMMITS.md for full guidelines.
- Creating commits
- Pushing to remote
- Creating branches
- Creating PRs with
gh
Never assume these actions are okay. Even for small changes, always confirm with the user first. Example: "Ready to commit. Should I proceed?"
NEVER attribute AI, or add to the commit message "Co-authored by Claude" or similar.
Non-obvious EDS 2.0 patterns are documented in documentation/adr/. Read the relevant ADR before changing or extending these patterns:
0002-use-vanilla-css-with-design-tokens-for-eds-2.md— why vanilla CSS over CSS-in-JS0004-component-conventions-for-eds-2.md— data attributes vs props, color scheme, density0004-spacing-approach-for-eds-2.md— spacing tokens, density modes, 4px baseline0005-typography-approach-for-eds-2.md— type scale,--_font-weight-*pseudo-private vars0005-use-aschild-slot-for-polymorphism.md—asChild+Slotfor polymorphic components
This file is the canonical source. Tool-specific configs add only what's unique to that tool:
| File | Purpose |
|---|---|
.claude/CLAUDE.md |
Claude Code: hooks, slash commands, settings |
.claude/settings.json |
Claude Code: permissions.deny for secrets + hook wiring |
.claude/rules/*.md |
Claude Code: path-scoped rules (/next, *.figma.tsx) |
.github/copilot-instructions.md |
GitHub Copilot: hub for path-scoped applyTo instructions |
.github/instructions/*.md |
GitHub Copilot: file-pattern specific rules |
.github/hooks/block-secrets.* |
Copilot CLI: preToolUse hook blocking secret-file access |
.github/hooks/format-on-edit.* |
Copilot CLI: postToolUse hook running eslint/stylelint --fix on edits |
.opencode/agent/*.md |
OpenCode: agent definitions |
.github/workflows/claude.yml |
@claude GitHub Action: system prompt points here |
When adding new conventions, update this file and let the tool-specific files reference it.
To verify the harness configs haven't drifted, run the cross-harness audit: documentation/agent-instructions/HARNESS_AUDIT.md is the canonical read-only playbook. Harness entry points: /audit-harnesses in Claude Code, the audit-harnesses prompt in Copilot, the audit-harnesses agent in OpenCode.