DS Code Authoring — Aetheron Design System
Before Writing Code
- Read
agent.mdin the DS repo root — it is the single source of truth. - Check existing components for patterns (
packages/ui/src/components/). - Run
pnpm lintbefore and after changes.
TypeScript Prop Patterns
Discriminated Unions for Coupled Props
When two props are logically coupled (one requires the other), use a discriminated union instead of flat optionals. This catches misuse at compile time.
// BAD — silent footgun: isStreaming=true without onStop compiles fine
interface Props {
isStreaming?: boolean;
onStop?: () => void;
}
// GOOD — isStreaming: true forces onStop to be present
type StreamingProps =
| { isStreaming?: false; onStop?: undefined; stopLabel?: undefined }
| { isStreaming: true; onStop: () => void; stopLabel?: string };
export type ChatComposerProps = BaseProps & StreamingProps;
Use undefined (not never) for the excluded branch — never breaks destructuring defaults.
Stories with Discriminated Unions
TypeScript cannot narrow a union via JSX spread. When a prop is state-driven (boolean), use conditional rendering to give TS a literal true:
{streaming ? (
<ChatComposer isStreaming ... />
) : (
<ChatComposer ... />
)}
Standard Prop Conventions
| Pattern | Convention |
|---|---|
| HTML passthrough | extends ComponentPropsWithoutRef<'element'> |
| Tone / color | tone?: 'primary' | 'secondary' | 'danger' |
| Size | size?: 'sm' | 'md' | 'lg' |
| Defaults | Destructure with defaults, not defaultProps |
| Class merging | clsx('base-classes', className) — consumer className always last |
DaisyUI Class Pitfalls
btn-outline Forces Primary Border Color
btn-outline applies the primary (blue) border color with higher specificity. Adding border-base-300 does NOT override it.
// BAD — border will be blue, not grey
className='btn btn-outline border-base-300'
// GOOD — btn-ghost has no border; manual border wins
className='btn btn-ghost border border-base-300'
cursor-not-allowed on Parent Labels
When a <label> sets cursor-not-allowed (for disabled state), it bleeds onto all children, including nested clickable buttons. If a child button should remain clickable, add cursor-pointer directly on it. See agent.md "Cursor Rules" for the full cursor spec.
DaisyUI Shared CSS Variables
Changing --radius-*, --size-*, --border, --depth, or --noise in theme files affects multiple DaisyUI components. Always check consumers in node_modules/daisyui/components/ before modifying.
Cursor Rules (see agent.md "Cursor Rules" for full spec)
Required only on custom clickable elements. DaisyUI base classes (.btn, .tab, .swap, .checkbox, .radio, .toggle, .input, .select, .textarea, .range, .rating > input) already set cursor: pointer — do not duplicate.
Native <button> defaults to cursor: default, NOT pointer. Raw <button> without .btn still needs cursor-pointer.
// ✅ Custom button (no .btn class)
<button className={clsx('p-2 rounded-md cursor-pointer disabled:cursor-not-allowed', className)} />
// ✅ Clickable div (custom card, summary, etc.)
<div className='cursor-pointer'>...</div>
// ❌ Don't duplicate — .btn already sets cursor: pointer
<button className='btn cursor-pointer' />
// ❌ Don't ship a clickable element without cursor
<div
For disabled states, pair disabled:cursor-not-allowed alongside cursor-pointer so the same element handles both states declaratively.
Storybook Stories
Timer Cleanup
Always clear setTimeout / setInterval in stories:
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); };
}, []);
const start = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => { /* ... */ }, 4000);
};
const stop = () => {
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
setState(false);
};
Play Functions for Behavioural Guarantees
Use Storybook play functions to verify interactive behaviour (not just visuals):
import { expect, fn, userEvent, within } from 'storybook/test';
const mockSend = fn();
export const StreamingStatic: Story = {
render: () => <ChatComposer isStreaming />,
play: async ({ canvasElement }) => {
mockSend.mockClear();
const canvas = within(canvasElement);
const textarea = canvas.getByRole('textbox');
await userEvent.type(textarea, 'hello');
await userEvent.keyboard('{Enter}');
expect(mockSend).not.toHaveBeenCalled();
},
};
Story Conventions
- Import
storybook/test(not@storybook/test) — Storybook v9 re-export. - Module-level
fn()for mocks; callmockClear()insideplay. - One render function component per interactive story (
function StoryName() { ... }). - Static stories can use arrow functions directly.
Icon Usage
Use short PascalCase form without the Icon prefix:
<Icon name='ArrowUp' size='sm' /> // resolves to IconArrowUp
<Icon name='PlayerStopFilled' size='sm' /> // resolves to IconPlayerStopFilled
The Icon component silently returns null for unknown names — no runtime warning. Double-check icon names against @tabler/icons-react exports.
Pre-Commit Checklist
- No
any, nots-ignore, noeslint-disable - Props typed with discriminated unions where appropriate
- Stories clean up timers on unmount
- Interactive stories have play functions for key behaviours
-
pnpm lintpasses with zero warnings -
pnpm buildsucceeds - DaisyUI class specificity verified (especially
btn-outlinevsbtn-ghost) - Every custom clickable element has
cursor-pointer(anddisabled:cursor-not-allowedif it has a disabled state); no duplication on DaisyUI base classes