디자인 규칙
opt-ui를 사용하는 프로젝트가 지켜야 하는 접근성·스타일링·안티패턴 규칙과, 각 규칙을 실제로 막는 주체를 정리합니다.
reopt design업데이트
개요
규칙은 총 56개입니다. 각 규칙에는 무엇이 이것을 막는지가 명시되어 있습니다 — 11개는 bun run lint가 CI에서 차단하고, 38개는 /opt-ui-guide 스킬이 스캔하며, 6개는 코드 리뷰에서 확인합니다.
이 페이지와 스킬의 레퍼런스 문서는 모두 apps/web/data/design-rules.ts 한 파일에서 나옵니다. 생성기는 렌더링 전에 규칙이 참조하는 Oxlint 룰이 실제로 존재하는지, advisory 표시가 루트 설정의 on/off와 맞는지, 플러그인의 모든 룰이 문서 항목을 갖는지 검증합니다. 따라서 여기 적힌 “무엇이 막는지”는 CI가 실제로 하는 일과 어긋날 수 없습니다.
접근성 (12)
스크린 리더와 키보드 사용자가 화면에 도달할 수 있게 하는 규칙입니다. 대부분 자동 검출이 어려워 스킬 스캔과 리뷰가 담당합니다.
A01Interactive element must have accessible name
error/opt-ui-guide 스킬 스캔
Every interactive element (`<button>`, `<a>`, `Button`, `MenuItem`, …) must expose an accessible name via visible text children, `aria-label`, or `aria-labelledby` pointing at a visible element.
지양
<button onClick={handleClick}>
<IconTrash />
</button>권장
<button onClick={handleClick} aria-label="Delete item">
<IconTrash />
</button>검출 Find `<button`, primitive button wrappers, and `<a ` elements. Check for text children, `aria-label`, or `aria-labelledby`. Icon-only buttons (children is only a component starting with `Icon` or `<svg>`) without `aria-label` trigger this rule.
A02Images must have alt text or aria-hidden
error/opt-ui-guide 스킬 스캔
All `<img>` elements must have `alt`. Decorative images use `alt=""` or `aria-hidden="true"`. SVGs used as icons must be `aria-hidden="true"`.
지양
<img src="/logo.png" />
<svg viewBox="0 0 24 24">...</svg>권장
<img src="/logo.png" alt="Company logo" />
<svg viewBox="0 0 24 24" aria-hidden="true">...</svg>검출 Find `<img` without `alt`. Find `<svg` without `aria-hidden` that is not inside a labeled button.
A03Form inputs must have associated label
error/opt-ui-guide 스킬 스캔
Every `<input>`, `<select>`, `<textarea>`, and opt-ui form control must have a `<label>` with matching `htmlFor`/`id`, an `aria-label`, or an `aria-labelledby`.
지양
<input type="text" placeholder="Search..." />권장
<label htmlFor="search">Search</label>
<input id="search" type="text" placeholder="Search..." />검출 Find `<input`, `<select`, `<textarea` elements. Check for a `<label htmlFor=` matching the `id`, or `aria-label`/`aria-labelledby` on the element. An opt-ui `<FormInput>` with a `name` prop that has a corresponding `<FormLabel>` is acceptable.
A04Error messages must use aria-describedby
error/opt-ui-guide 스킬 스캔
Error messages tied to a form input must be linked via `aria-describedby` and carry `role="alert"` so they are announced.
지양
<input />
{error && <span className="text-red-500">{error}</span>}권장
<input aria-invalid={!!error} aria-describedby="email-error" />
{error && (
<span id="email-error" role="alert">
{error}
</span>
)}검출 Find an error `<span>`/`<p>` rendered near an input where the input has no `aria-describedby` pointing at the error element's `id`.
A05Semantic roles for custom widgets
error/opt-ui-guide 스킬 스캔
Custom widgets must declare their role explicitly when HTML semantics do not convey it: toggle → `role="switch"`, tab panel → `role="tabpanel"`, status indicator → `role="status"`, alert → `role="alert"`.
예외 opt-ui primitives with built-in roles are exempt.
검출 Look for components rendering `<div>`/`<span>` with toggle, switch, or status behavior but no `role` attribute.
A06Focus must be visible
errorOxlint opt-ui/no-outline-none-without-focus-visible — bun run lint가 차단
All interactive elements need visible focus indication. Use `focus-visible:ring-2 focus-visible:ring-blue-600` or `data-[focus-visible]:outline-2 data-[focus-visible]:outline-blue-600`.
Do NOT use `focus:` — use `focus-visible:` so mobile taps do not flash a focus ring.
지양
<button className="outline-none">Click</button>권장
<button className="outline-none focus-visible:ring-2 focus-visible:ring-blue-600">
Click
</button>검출 Find interactive elements with `outline-none` but no `focus-visible:` or `data-[focus-visible]:` styles. Primitives that receive focus tokens (`OPT_FOCUS`, `OPT_FOCUS_VISIBLE`) via `cn()` are acceptable.
A07onClick must have keyboard equivalent
warn/opt-ui-guide 스킬 스캔
Non-button elements with `onClick` must also handle `onKeyDown` for Enter/Space, or use a semantic element (`<button>`, `<a>`, `Button`).
지양
<div onClick={handleClick}>Clickable area</div>권장
<button onClick={handleClick}>Clickable area</button>검출 Find `<div`/`<span` with `onClick` and no `onKeyDown`. Overlaps X01, but A07 is specifically about keyboard equivalence.
A08Color-only status must have text alternative
warn/opt-ui-guide 스킬 스캔
Status indicators conveyed only by color (green/red/yellow dots) must also provide text or an `aria-label` for colorblind users.
색상만으로 상태를 전달하면 색각 이상 사용자와 스크린 리더 사용자에게 아무 정보도 남지 않습니다.
지양
권장
지양
<span className="h-2 w-2 rounded-full bg-green-500" />권장
<span className="h-2 w-2 rounded-full bg-green-500" aria-label="Active" />검출 Find elements with only dot-style classes (`h-2 w-2 rounded-full bg-{color}-500`) and no `aria-label` or adjacent text.
A09Dialog/modal must trap focus
warn/opt-ui-guide 스킬 스캔
Dialogs and modals must trap focus. Use opt-ui's `Dialog`, which handles this automatically.
검출 Find a raw `<div>` rendered as a modal (`fixed inset-0` or a portal) without opt-ui `Dialog`. Hand-rolled focus traps are acceptable but should be flagged as `info`.
A10Skip link or landmark regions
info/opt-ui-guide 스킬 스캔
Page-level Blocks should use landmark regions (`<main>`, `<nav>`, `<aside>`, `<header>`, `<footer>`) or provide a skip link.
검출 In Block files, check for at least one landmark element or equivalent `role` (`role="main"`, `role="navigation"`).
A11Heading hierarchy must be logical
info/opt-ui-guide 스킬 스캔
Components must not skip heading levels — an `<h1>` followed by `<h3>` with no `<h2>` between them.
레벨을 건너뛰면 스크린 리더의 제목 목록에서 문서 구조가 무너집니다.
지양
h1 · 워크스페이스
h3 · 멤버 목록 (h2 없음)
권장
h1 · 워크스페이스
h2 · 설정
h3 · 멤버 목록
검출 Collect `<h1>`–`<h6>` in the file and check for skipped levels.
A12Live region for dynamic content
info/opt-ui-guide 스킬 스캔
Content that updates dynamically (status changes, loading states, notifications) should use `aria-live="polite"` or `aria-live="assertive"`.
검출 Find conditional rendering of status text or notifications where the container has no `aria-live`.
스타일링 (13)
색상·간격·테마가 프로젝트 전반에서 일관되게 유지되도록 하는 규칙입니다.
S01Use OPT_* tokens instead of legacy token constants
warnOxlint opt-ui/no-legacy-tokens — bun run lint가 차단
Legacy token constants are retired. Import the `OPT_*` design tokens instead so themes stay in sync.
S02No hardcoded zinc colors
warnOxlint opt-ui/no-zinc-colors — bun run lint가 차단
Replace hardcoded `zinc-*` colors with opacity-based equivalents.
| Zinc pattern | Opacity replacement |
|---|---|
| `text-zinc-900` | `text-black/80` |
| `text-zinc-700` | `text-black/80` (or `OPT_TEXT_PRIMARY`) |
| `text-zinc-500` | `text-black/45` (or `OPT_TEXT_TERTIARY`) |
| `text-zinc-400` | `text-white/35` (dark mode) |
| `text-zinc-300` | `text-white/55` (dark mode) |
| `text-zinc-200` | use the `OPT_BORDER` pattern |
| `text-zinc-100` | use `bg-black/[0.04]` |
| `border-zinc-200` | `border-black/[0.13]` |
| `border-zinc-700` | `border-border` |
| `bg-zinc-100` | `bg-black/[0.04]` |
| `bg-zinc-800` | `bg-bg-muted` |
| `bg-zinc-900` | `bg-surface` |
지양
<div className="text-zinc-700 dark:text-zinc-300">권장
<div className="text-black/80 dark:text-white/80">검출 Regex for `zinc-\d{2,3}` in className strings and template literals.
S03Dark mode background must use a semantic surface token
warn/opt-ui-guide 스킬 스캔
Dark mode backgrounds use `bg-surface`, not a hardcoded HSL or `zinc-900`. The default dark theme defines distinct base, raised, and overlay surface levels, and semantic utilities keep consumers aligned when those values change.
지양
<div className="bg-white dark:bg-zinc-900">권장
<div className="bg-white dark:bg-surface">검출 Find `dark:bg-zinc-900` in className strings.
S04Focus ring must use blue-600
warnOxlint opt-ui/prefer-blue-600-focus — bun run lint가 차단
Focus indicators use `blue-600`, not `blue-500`, to match the opt-ui focus color.
지양
focus-visible:ring-blue-500권장
focus-visible:ring-blue-600검출 Find `blue-500` in focus-related classes (`ring-blue-500`, `outline-blue-500`).
S05Border opacity must use black/white alpha
warn/opt-ui-guide 스킬 스캔
Borders use the opacity-based pattern so they hold up across themes.
지양
border-zinc-200 dark:border-zinc-700권장
border-border검출 Find `border-zinc-` patterns in className strings.
S06Consistent border radius
info/opt-ui-guide 스킬 스캔
Use a consistent border radius per element type within a file.
| Radius | Applies to |
|---|---|
| `rounded-lg` | containers, inputs, buttons |
| `rounded-md` | inner items (menu items, list items) |
| `rounded-xl` | elevated cards and surfaces |
| `rounded-full` | badges, avatars, dots |
같은 종류의 요소에 반경이 섞이면 카드 모서리가 미묘하게 어긋나 보입니다.
지양
rounded-lg 항목
rounded-sm 항목
rounded-xl 항목
권장
첫 번째 항목
두 번째 항목
세 번째 항목
검출 Collect all `rounded-*` classes and flag files that mix `rounded-lg` and `rounded-md` for the same kind of element.
S07Dark mode class must pair with light mode
warn/opt-ui-guide 스킬 스캔
Every `dark:` utility needs a corresponding light-mode class. An unpaired dark class means the light theme was never styled.
지양 — dark text with no light equivalent
<div className="dark:text-white/80">권장
<div className="text-black/80 dark:text-white/80">예외 `dark:ring-offset-*` and `dark:placeholder:*` may stand alone.
검출 Find `dark:` prefixed classes and verify a non-dark equivalent exists on the same element.
S08Prefer token import over inline repetition
info/opt-ui-guide 스킬 스캔
If the same Tailwind class combination appears three or more times in a file, extract it to a local constant or import an existing token.
검출 Find className strings that repeat the same 3+ class combination more than twice.
S09No arbitrary color values outside tokens
info/opt-ui-guide 스킬 스캔
Avoid arbitrary color values (`bg-[#ff0000]`, `text-[rgb(255,0,0)]`). Use design system colors (blue, red, emerald, amber) or opacity-based tokens.
예외 HSL dark mode backgrounds `hsl(204 4% *)` are acceptable.
검출 Find `[#`, `[rgb`, or `[hsl` in className strings, excluding the standard dark-mode HSL values.
S10No !important in Tailwind classes
info/opt-ui-guide 스킬 스캔
Avoid the `!important` modifier. If specificity fights you, restructure the component instead.
지양
<div className="!text-red-500">권장
<div className="text-red-500">검출 Find a `!` prefix on Tailwind class names within className strings.
S11Prefer semantic spacing tokens
warnOxlint opt-ui/prefer-semantic-spacing (advisory — CI에서 off, 스킬이 검사)
Use semantic spacing tokens instead of raw numeric values so spacing stays consistent across themes.
CSS variables: `--opt-space-section` (1.5rem), `--opt-space-group` (1rem), `--opt-space-element` (0.5rem).
| Raw value | Semantic token |
|---|---|
| `gap-6`, `space-y-6` | `gap-section` — between major sections (24px) |
| `gap-4`, `space-y-4` | `gap-group` — between related items (16px) |
| `gap-2`, `space-y-2` | `gap-element` — between small items (8px) |
시맨틱 스페이싱은 섹션·그룹·요소의 위계를 눈에 보이게 만듭니다. 임의 숫자는 위계를 지웁니다.
지양
알림 설정
업데이트를 알림으로 받습니다
보안
2단계 인증을 사용합니다
권장
알림 설정
업데이트를 알림으로 받습니다
보안
2단계 인증을 사용합니다
지양 — raw numeric spacing
<div className="space-y-6">권장
<div className="flex flex-col gap-section">검출 Find `gap-4`, `gap-6`, `gap-8`, `space-y-4`, `space-y-6`, `space-y-8` in className strings and suggest semantic equivalents.
S12No space-y/space-x utilities
warnOxlint opt-ui/no-space-y (advisory — CI에서 off, 스킬이 검사)
`space-y-*` and `space-x-*` compile to the `> * + *` selector, which only applies margin between direct sibling children. Wrap the content in a single element and the spacing silently becomes zero.
Always use `flex` plus `gap-*` instead.
래퍼가 하나 끼는 순간 space-y의 `> * + *` 선택자가 형제를 찾지 못해 간격이 0이 됩니다. gap은 영향을 받지 않습니다.
지양
첫 번째 카드
래퍼 안에 있습니다
두 번째 카드
간격이 사라졌습니다
권장
첫 번째 카드
래퍼 안에 있습니다
두 번째 카드
간격이 유지됩니다
지양 — breaks as soon as a wrapper wraps the content
<div className="space-y-6">권장 — gap works regardless of nesting
<div className="flex flex-col gap-section">검출 Find `space-y-` or `space-x-` in className strings, template literals, and `cn()` arguments.
S13No raw color classes
warnOxlint opt-ui/no-raw-color (advisory — CI에서 off, 스킬이 검사)
Raw color utilities (`text-white`, `bg-amber-500`, and the rest of the Tailwind palette) bypass theming. Use semantic tokens — `text-accent-fg`, `text-danger-fg`, `text-text-{primary,secondary,tertiary}`, `bg-surface` — or their alpha aliases such as `text-accent-fg/60`.
For composite item active state, drive children from `OPT_ACTIVE_ITEM` (`lib/styles.ts`) with `data-[active-item]:text-accent-fg/{N}` or `bg-accent-fg/{N}`.
Intentional exceptions (`text-white` over a saturated badge background, marquee selection, padding visualisation) need an inline suppression with a reason.
검출 The `dark:` prefix is allowed automatically; other raw palette classes are reported.
안티패턴 (11)
리뷰에서 반복적으로 지적되는 실수들입니다. 절반 이상이 Oxlint로 차단됩니다.
X01div/span as interactive element
errorOxlint opt-ui/no-div-onclick — bun run lint가 차단
Never use `<div>` or `<span>` as a clickable element. Non-semantic interactive elements are invisible to screen readers and unreachable by keyboard — use `<button>`, `<a>`, or an accessible component.
지양
<div onClick={handleClick} className="cursor-pointer">
Click me
</div>권장
<button onClick={handleClick}>
Click me
</button>예외 `<div onClick>` inside a `role="grid"` or composite context where a framework manages keyboard navigation.
검출 Find `<div`/`<span` with an `onClick` prop, excluding elements carrying `role="button"`, `role="option"`, `role="tab"`, or `role="gridcell"`.
X02cursor-pointer on non-interactive element
warn/opt-ui-guide 스킬 스캔
`cursor-pointer` on a `<div>` or `<span>` is a code smell: the element should be a button. If it really is non-interactive, drop the class.
지양
<div className="cursor-pointer" onClick={handleClick}>권장
<button className="cursor-pointer" onClick={handleClick}>검출 Find `cursor-pointer` in className on `<div`/`<span` elements.
X03onClick on non-interactive element without role
error/opt-ui-guide 스킬 스캔
In the rare case `onClick` must live on a non-button element, it needs `role="button"` (or the appropriate role), `tabIndex={0}`, and an `onKeyDown` handler for Enter/Space.
지양
<div onClick={handleClick}>허용 — prefer `<button>`
<div role="button" tabIndex={0} onClick={handleClick} onKeyDown={handleKeyDown}>검출 Find `<div`/`<span` with `onClick` and no `role` attribute.
X04Prefer the opt-ui Form over a raw form element
warnOxlint opt-ui/prefer-primitives-form (advisory — CI에서 off, 스킬이 검사)
Use the opt-ui `Form` primitives rather than a raw `<form>`, so validation, labelling, and submission stay consistent.
X05No legacy and OPT_* tokens in the same file
warnOxlint opt-ui/no-mixed-tokens — bun run lint가 차단
Mixing legacy token constants with `OPT_*` tokens in one file produces inconsistent theming. Migrate the file wholesale.
X06No hardcoded user-facing strings
warnOxlint opt-ui/require-labels-no-hardcode (advisory — CI에서 off, 스킬이 검사)
User-facing copy belongs in a `Labels` interface so consumers can translate it.
X07console.log in production code
warnOxlint opt-ui/no-console-log — bun run lint가 차단
Remove `console.log`, `console.debug`, and `console.info`. `console.warn` and `console.error` are fine for genuine warnings and failures.
지양
console.log("render count:", count);허용
console.warn("Deprecated prop used");
console.error("Failed to load data", error);검출 Find `console.log(`, `console.debug(`, `console.info(` in non-test files.
X08any type assertion
warn/opt-ui-guide 스킬 스캔
Avoid `as any`, `: any`, and `any[]`. Model the type properly, or use `unknown` with a type guard.
지양
const data = response as any;
function process(items: any[]) { ... }권장
const data = response as ApiResponse;
function process(items: unknown[]) { ... }검출 Find `as any`, `: any`, `any[]`, `any>`, `<any` in TypeScript code, excluding comments.
X09oxlint-disable / ts-ignore without explanation
infoOxlint opt-ui/require-suppress-explanation (advisory — CI에서 off, 스킬이 검사)
Every suppression comment must say why the suppression is needed.
Prefer `@ts-expect-error` over `@ts-ignore` — it errors once the suppression is no longer needed.
지양
// oxlint-disable-next-line
// @ts-ignore권장
// oxlint-disable-next-line react/exhaustive-deps -- intentionally empty deps
// @ts-expect-error -- type mismatch, fixed in next version검출 Find `oxlint-disable`, `@ts-ignore`, `@ts-expect-error` comments and check for explanatory text after `--` or on the same line.
X10Deeply nested ternary expressions
infoOxlint opt-ui/no-deep-ternary (advisory — CI에서 off, 스킬이 검사)
Ternaries nested three or more levels deep are hard to read. Use early returns, `if`/`else`, or a lookup object.
지양
const color =
status === "active"
? type === "admin"
? role === "super"
? "blue"
: "green"
: "yellow"
: "gray";권장
function getColor(status: string, type: string, role: string) {
if (status !== "active") return "gray";
if (type !== "admin") return "yellow";
return role === "super" ? "blue" : "green";
}검출 Count ternary nesting depth and flag at depth 3 or more.
X11No deprecated opt-ui components or props
warnOxlint opt-ui/no-deprecated-api — bun run lint가 차단
Components and props carrying a `deprecated` notice in `component-catalog.json` must not be used in new code. The notice names the replacement.
The rule is generated from component metadata: adding `deprecated` to a `ComponentMeta` or `PropDef` is the only step needed to start blocking a usage.
지양
<Switch label="알림" description="설명 텍스트" />권장
<Switch label="알림" hint="설명 텍스트" />검출 Resolve each JSX element back to the `@reopt-ai/opt-*` export it was imported from, then look the component and its props up in the catalog.
컴포넌트 · 레이어 (14)
모노레포 안에서 컴포넌트를 작성할 때 지켜야 하는 구조 계약입니다. 소비자 프로젝트에는 적용되지 않습니다.
C01No upward layer imports
errorOxlint opt-ui/no-upward-imports — bun run lint가 차단
Imports flow Core → Shells → Blocks and never the other way. A Core component may not import from `shells/`.
C02No cross-package imports from retired packages
errorOxlint opt-ui/no-cross-package-import — bun run lint가 차단
opt-ui must not import from the retired opt-ui-surface package.
C03"use client" directive on interactive files
warnOxlint opt-ui/require-use-client (advisory — CI에서 off, 스킬이 검사)
Files using React hooks, `forwardRef`, `createContext`, `memo`, `lazy`, inline JSX event handlers, or function props need a `"use client"` directive at the top.
C03bNo unnecessary "use client"
warnOxlint opt-ui/no-unnecessary-use-client (advisory — CI에서 off, 스킬이 검사)
Static components stay server-safe so the RSC tree keeps its efficiency. Remove `"use client"` where no interactive pattern is present.
C04Props interface follows {ComponentName}Props
warnOxlint opt-ui/props-naming (advisory — CI에서 off, 스킬이 검사)
A component's props interface is named after the component.
C05forwardRef components set displayName
warnOxlint opt-ui/require-display-name (advisory — CI에서 off, 스킬이 검사)
Without `displayName`, React DevTools shows the component as anonymous.
C06Shells and Blocks define Labels interfaces
warnOxlint opt-ui/require-labels (advisory — CI에서 off, 스킬이 검사)
Every user-facing string is injectable through a `Labels` interface for i18n.
C07Data components handle empty state
infoOxlint opt-ui/require-empty-state (advisory — CI에서 off, 스킬이 검사)
A component that renders a collection must render something when it is empty.
C08Components are exported from index.ts
info테스트 — packages/opt-ui/src/__tests__/meta-source-files.test.ts
A component not exported from the barrel is unreachable by consumers.
C09Block root uses BlockLayout
errorOxlint opt-ui/require-block-layout — bun run lint가 차단
A Block root must use `BlockLayout` (or `createBlock`) rather than applying `space-y-*`/`gap-*` directly.
C10Blocks accept a loading prop
warnOxlint opt-ui/block-loading-prop (advisory — CI에서 off, 스킬이 검사)
A Block owns its loading presentation, so it must accept `loading`.
C11Blocks accept header/actions slots
warnOxlint opt-ui/block-slots (advisory — CI에서 off, 스킬이 검사)
Blocks expose `header` and `actions` slots so pages can compose around them.
D01Exported API carries JSDoc
warnOxlint opt-ui/require-export-jsdoc (advisory — CI에서 off, 스킬이 검사)
Exported functions, interfaces, and types document what they are for.
P02Components carry a data-opt-id
infoOxlint opt-ui/require-opt-id (advisory — CI에서 off, 스킬이 검사)
`data-opt-id` identifies a component instance for devtools and E2E targeting. Scheduled for promotion to `error`.
인터랙션 · 애니메이션 (6)
휴리스틱이 강해 룰로 만들면 오탐이 많은 항목들입니다. 리뷰에서 확인합니다.
I01Disclosure components use the animated prop
warn코드 리뷰
Expand/collapse should run through `animated` (`data-[enter]`/`data-[leave]`).
I02Transitions must have both enter and leave states
warn코드 리뷰
A transition defined only for enter snaps on the way out.
I03Popovers, menus, and tooltips set gutter
warn코드 리뷰
Floating surfaces need a 4–8px `gutter` so they do not touch their trigger.
I04No inline object/array creation in JSX props
info코드 리뷰
A fresh object literal each render defeats memoisation — hoist it or use `useMemo`.
I05Static data lives at module level
info코드 리뷰
Data that never changes should not be rebuilt inside the component body.
I06Handlers passed to memoized children use useCallback
info코드 리뷰
An unstable handler identity re-renders the memoized child on every parent render.