Guidelines for AI coding agents working on Nuclear Music Player.
Nuclear is a music player desktop app built with Tauri (Rust + React). This is a pnpm monorepo managed with Turborepo.
@nuclearplayer/player- Main Tauri app (React + Rust)@nuclearplayer/ui- Shared UI components@nuclearplayer/plugin-sdk- Plugin system (published to npm)@nuclearplayer/model- Data model@nuclearplayer/themes- Theming system@nuclearplayer/hifi- Advanced HTML5 audio component@nuclearplayer/tailwind-config- Shared Tailwind config@nuclearplayer/eslint-config- Shared linting rules@nuclearplayer/i18n- Internationalization@nuclearplayer/storybook- Component demos@nuclearplayer/docs- Documentation@nuclearplayer/website- Project website (Astro)
# Development
pnpm dev # Run player in dev mode
pnpm storybook # Run Storybook
# Build
pnpm build # Build all packages
pnpm tauri build # Build Tauri app
# Quality
pnpm lint # Lint all packages
pnpm lint:fix # Lint and auto-fix
pnpm type-check # TypeScript checks
pnpm test # Run all tests
pnpm test:coverage # Run tests with coverage
pnpm clean # Clean build artifacts
# Package-specific testing
pnpm --filter @nuclearplayer/ui test -- src/components/Badge/Badge.test.tsx
pnpm --filter @nuclearplayer/ui test -- --testNamePattern="renders"
# Update snapshots (run at root for all, or filter to a specific package)
# At root
pnpm test -- -u
# Filtering for a specific package
pnpm --filter @nuclearplayer/ui test -- -u
# After cd'ing into a package
pnpm test -u- Prioritize readability over cleverness
- No comments in code - explain reasoning in chat/commits
- Avoid premature abstractions - start concrete, extract later
- Small, focused changes over large dumps
- Never commit unless explicitly asked
- Use
typenotinterface(except when merging is required) - No magic numbers - extract into named constants
- Strict mode with
noUnusedLocalsandnoUnusedParameters - Do not use one-letter variable names.
AVOID:
(b) => b.buildIndexEntry()PREFER:(build) => build.buildIndexEntry()
import { cva, VariantProps } from 'class-variance-authority';
import { ComponentProps, FC } from 'react';
import { cn } from '../../utils';
const componentVariants = cva('base-classes', {
variants: { /* ... */ },
defaultVariants: { /* ... */ },
});
type ComponentProps = ComponentProps<'div'> &
VariantProps<typeof componentVariants>;
export const Component: FC<ComponentProps> = ({
className,
variant,
...props
}) => (
<div className={cn(componentVariants({ variant, className }))} {...props} />
);- Use
const Component: FC<Props>notfunction Component() - Compound components (
Component.Sub) for complex widgets - Keep business logic out of UI components
When adding a new component to @nuclearplayer/ui:
- Create component directory:
packages/ui/src/components/MyComponent/MyComponent.tsx- implementationMyComponent.test.tsx- tests (aim for 100% coverage)index.ts- re-exports
- Export from
packages/ui/src/components/index.ts - Add Storybook story in
packages/storybook/src/MyComponent.stories.tsx - Include snapshot test(s) covering all variants
- CSS-first config in
packages/tailwind-config/global.css - Theme colors:
bg-background,text-foreground,bg-primary - Accents:
accent-green,accent-yellow,accent-purple,accent-blue,accent-orange,accent-cyan,accent-red - Use
cn()for conditional classes,cva()for variants
- Zustand - persistent UI state
- React state - local, temporary state
- TanStack Query v5 - HTTP requests/server state
- TanStack Router - client-side routing
- Icons: Lucide React (not heroicons, not font-awesome)
- Toasts: Sonner
- Dates: Luxon
- Utilities: lodash-es (use individual imports:
import isEqual from 'lodash-es/isEqual') - HTTP: Native fetch via ApiClient base class (no axios)
A "domain" is a feature area exposed to plugins (e.g., settings, queue, favorites). When adding a new domain:
-
Types (
packages/plugin-sdk/src/types/myDomain.ts)- Define the
MyDomainHostinterface (the contract between player and SDK) - Export any related types plugins will use
- Define the
-
API class (
packages/plugin-sdk/src/api/myDomain.ts)- Create a class that wraps the host and exposes methods to plugins
- Add to
NuclearAPIconstructor inpackages/plugin-sdk/src/api/index.ts
-
Store (
packages/player/src/stores/myDomainStore.ts)- Zustand store holding the domain state
- Persists to disk via
@tauri-apps/plugin-storeif needed
-
Host (
packages/player/src/services/myDomainHost.ts)- Implements the
MyDomainHostinterface - Bridges the SDK API to the Zustand store
- Passed to
NuclearAPIwhen initializing plugins
- Implements the
Live in packages/player/src/apis/. Use ApiClient base class (fetch→json→Zod).
- Validate external data with Zod schemas
- Export singleton instances
- One class per external service
All user-facing strings go through i18n - no hardcoded UI text.
import { useTranslation } from '@nuclearplayer/i18n';
const { t } = useTranslation();
<span>{t('navigation.settings')}</span>Add new strings to packages/i18n/src/locales/en_US.json only. Other locales come from Crowdin.
Tests use Vitest + React Testing Library. Globals enabled (describe, it, expect, vi).
- Integration tests over unit tests for user-facing behavior. Render real components and assert on DOM content rather than verifying mock calls.
- Unit tests for utilities - standalone data structures (RingBuffer, parsers) deserve isolated tests. Use them sparingly.
- Test user behavior, not implementation details
- Minimize mocks - only mock external deps (HTTP, FS, Tauri)
- Snapshot tests: prefix with
(Snapshot), basic rendering only - Never use
querySelectorin tests. Prefer RTL queries. - When semantic queries aren't possible, add
data-testidattributes. And don't be shy with them - Don't use defensive measures like try-catch or conditional checks in tests. The test will fail anyway if our assumptions are wrong.
When building a new view, write the test wrapper and tests before any implementation code. The tests describe what the user sees and does — they define the contract. Then implement to make them pass.
Don't start with unit tests for internal utilities (grouping functions, registries, etc.). Start from the outside: what does the user see on the page? The internal structure is an implementation detail that falls out of making the tests green.
Player views and some components use a *.test-wrapper.tsx file that creates a domain-specific abstraction layer over the DOM. This lets tests read like user stories, and if the implementation changes, only the wrapper needs updating.
Wrapper conventions:
- Use getters for element queries:
get emptyState(),get cards()— notgetEmptyState() - Use nested objects for interactive elements:
createButton: { get element(), async click() } - Use methods for multi-step user actions:
async openContextMenu(title: string) - Tests should use
Wrapper.emptyState,Wrapper.cards,Wrapper.createButton.click()— not barescreenqueries - The wrapper is the only place that knows about test IDs, roles, and DOM structure
- Don't use queryX methods in the wrapper - always get or find as appropriate.
- Never use fireEvent. Always use userEvent for interactions.
To populate the app with testing data, use fixtures. See packages/player/src/test/fixtures for examples.
Test wrappers can expose a fixtures object with factory methods that return pre-configured builders for common test scenarios. This keeps test setup readable and co-located with the wrapper, while the raw fixture data itself lives in packages/player/src/test/fixtures/.
// Dashboard.test-wrapper.tsx
import { TOP_TRACKS_RADIOHEAD } from '../../test/fixtures/dashboard';
export const DashboardWrapper = {
// ... mount, getters, etc.
fixtures: {
topTracksProvider() {
return new DashboardProviderBuilder()
.withCapabilities('topTracks')
.withFetchTopTracks(async () => TOP_TRACKS_RADIOHEAD);
},
},
};
// Dashboard.test.tsx
DashboardWrapper.seedProvider(DashboardWrapper.fixtures.topTracksProvider());We use builders to create test data and various entities cleanly. You can see them in packages/player/src/test/builders.
- A builder is a class that has an instance of the object it's building
- When the builder is instantiated, it creates a default object with reasonable defaults
- The builder has methods that mutate the object and return
thisfor chaining - The
build()method returns the final object, which can then be used in tests
// Playlists.test-wrapper.tsx
export const PlaylistsWrapper = {
async mount(): Promise<RenderResult> { /* ... */ },
get emptyState() {
return screen.queryByTestId('empty-state');
},
get cards() {
return screen.queryAllByTestId('card');
},
createButton: {
get element() {
return screen.getByTestId('create-playlist-button');
},
async click() {
await userEvent.click(this.element);
},
},
};
// Playlists.test.tsx — reads like a user story
it('shows empty state when no playlists', async () => {
await PlaylistsWrapper.mount();
expect(PlaylistsWrapper.emptyState).toBeInTheDocument();
});packages/ui/src/components/Badge/
Badge.tsx # Implementation
Badge.test.tsx # Tests
index.ts # Re-exports
__snapshots__/ # Vitest snapshots
- Neo-brutalist with premium polish - bold borders, purposeful shadows
- Premium, designed feel
- Animations via
framer-motionandtw-animate-css - Disable animations during high-friction moments (resize, drag)
- Avoid generic AI patterns (icon-grid cards, stock heroes, "Built with love" badges)
- pnpm with workspace protocol for internal deps
- Turborepo for task orchestration
- ESLint + Prettier run together
- Husky + lint-staged for pre-commit hooks
Use centralized configs from eslint-config and tailwind-config packages.
Assume TanStack Router routes regenerate on dev - don't regenerate manually.
Releases are triggered by git tags. The workflow builds for macOS (arm64/x64), Linux, and Windows.
# 1. Update version in packages/player/package.json
# 2. Update version in packages/player/src-tauri/tauri.conf.json
# 3. Commit the version bump
git add packages/player/package.json packages/player/src-tauri/tauri.conf.json && git commit -m "player@X.Y.Z"
# 4. Tag and push
git tag player@X.Y.Z
git push origin master --tagsThe release-player.yml workflow creates a GitHub release with platform binaries.
Published to npm via the release-plugin-sdk.yml workflow.
# 1. Update version in packages/plugin-sdk/package.json
# 2. Commit the version bump
git add packages/plugin-sdk/package.json && git commit -m "plugin-sdk@X.Y.Z"
# 3. Tag and push
git tag plugin-sdk@X.Y.Z
git push origin master --tagsThe workflow builds with build:npm, runs tests, and publishes to npm.