diff --git a/.gitignore b/.gitignore index 63adc91..dabd051 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ apps/*/.tanstack/tmp/ apps/*/.wrangler/ apps/*/dist/ apps/*/node_modules +packages/*/storybook-static/ +storybook-static/ # Local Pi runtime state .pi/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..c9b2787 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "nx-mcp": { + "command": "pnpm", + "args": [ + "exec", + "nx", + "mcp" + ], + "lifecycle": "lazy", + "directTools": true + } + } +} diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..e88efc9 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "kobalte", + "tsx": true, + "tailwind": { + "config": "", + "css": "../../packages/ui/src/styles.css", + "baseColor": "neutral", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "#components", + "ui": "@os-copilot/ui/components/ui", + "lib": "#lib", + "hooks": "#hooks", + "utils": "@os-copilot/ui/lib/utils" + }, + "registries": { + "@zaidan": "https://zaidan.carere.dev/r/{style}/{name}.json" + } +} diff --git a/apps/web/package.json b/apps/web/package.json index 6c2ba1e..24f02af 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,12 +22,18 @@ "@tanstack/solid-router-devtools": "catalog:", "@tanstack/solid-start": "catalog:", "better-auth": "catalog:", - "solid-js": "catalog:" + "solid-js": "catalog:", + "@os-copilot/ui": "workspace:*" }, "devDependencies": { "@cloudflare/workers-types": "catalog:", "vite": "catalog:", "vite-plugin-solid": "catalog:", "vitest": "catalog:" + }, + "imports": { + "#components/*": "./src/components/*.tsx", + "#lib/*": "./src/lib/*.ts", + "#hooks/*": "./src/hooks/*.ts" } } diff --git a/docs/prd-ui-package.md b/docs/prd-ui-package.md new file mode 100644 index 0000000..53eac99 --- /dev/null +++ b/docs/prd-ui-package.md @@ -0,0 +1,486 @@ +# PRD: Mobile-first Solid UI package + +Create `packages/ui` as a Solid + TypeScript component library for the Nx monorepo. The package must be mobile-first, Storybook-driven, Nx-cacheable, and strict ESM/tree-shakeable so apps and future slice libraries can reuse UI without pulling unnecessary components. + +## Decision summary + +| Topic | Decision | +| ---------------- | ------------------------------------------------------------------------------------------------------ | +| Package path | `packages/ui` | +| Runtime | Solid + TypeScript | +| Build | Vite library mode through Nx targets | +| Component source | Zaidan via `shadcn` CLI when available; custom implementation only when unavailable | +| Reference repo | `repos/arkitect-ui/packages/solid` for setup patterns and component inventory only; do not copy source | +| Stories location | `packages/ui/src/stories/ui/*` | +| Styling baseline | Zaidan Vega style, neutral theme, Inter font, Tailwind CSS v4 | +| Export model | Strict ESM, root barrel plus per-component subpath exports | +| Performance bar | Tree-shakeable component imports, externalized peers, cacheable Nx build/typecheck/storybook targets | + +## Goals + +- Provide a reusable Solid UI library consumed from monorepo apps and future package slices. +- Install Zaidan-provided components with the `shadcn` CLI instead of hand-copying reference code. +- Match the current Arkitect Solid UI component inventory where practical. +- Keep every public component documented in Storybook under `src/stories/ui/*`. +- Guarantee strict ESM outputs with granular imports for tree-shaking. +- Make build, typecheck, lint, test, and Storybook targets cacheable through Nx. + +## Non-goals + +- Do not copy component source from `repos/arkitect-ui`. +- Do not create React components. +- Do not publish to npm in the first milestone. +- Do not build a docs site beyond Storybook. +- Do not implement custom versions of components already available from Zaidan unless a design/product gap is documented. + +## Reference findings + +`repos/arkitect-ui/packages/solid` is a useful reference for package shape: + +- `project.json` defines Nx targets for Vite build, Vitest, lint, Storybook serve, and Storybook build. +- `vite.config.ts` uses Solid, declaration generation, library entry, and externalized runtime packages. +- `package.json` uses `type: "module"` and an `exports` map. +- `.storybook/main.ts` uses Storybook Solid + Vite integration. +- Components are flat in `src/components/ui`, with colocated stories in the reference. For this package, stories must move to `src/stories/ui/*`. + +## Zaidan setup + +Zaidan is a shadcn-compatible Solid registry, not a runtime npm UI package. The implementation must configure `components.json` for the Zaidan Kobalte registry and then install registry items with `pnpm dlx shadcn@latest add`. + +Baseline design-system command: + +```bash +pnpm dlx shadcn@latest add @zaidan/font-inter @zaidan/neutral @zaidan/style-vega +``` + +Expected registry config shape: + +```json +{ + "style": "kobalte", + "tsx": true, + "tailwind": { + "baseColor": "neutral" + }, + "iconLibrary": "lucide", + "registries": { + "@zaidan": "https://zaidan.carere.dev/r/{style}/{name}.json" + } +} +``` + +## shadcn monorepo routing + +shadcn has explicit monorepo support: each workspace that runs the CLI needs its own `components.json`, and aliases decide where generated files land and how imports are rewritten. For this project, the app should keep a shadcn setup, but UI components installed from the app must route to `packages/ui`. + +### App workspace contract + +`apps/web/components.json` should point UI and shared utilities at the UI workspace package while keeping app-owned components local: + +```json +{ + "style": "kobalte", + "tsx": true, + "tailwind": { + "config": "", + "css": "../../packages/ui/src/styles.css", + "baseColor": "neutral", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "#components", + "ui": "@os-copilot/ui/components/ui", + "lib": "#lib", + "hooks": "#hooks", + "utils": "@os-copilot/ui/lib/utils" + }, + "registries": { + "@zaidan": "https://zaidan.carere.dev/r/{style}/{name}.json" + } +} +``` + +With this routing, running from the app should install UI primitives into `packages/ui` and rewrite app imports to the workspace package: + +```bash +cd apps/web +pnpm dlx shadcn@latest add @zaidan/button +``` + +Expected app import: + +```ts +import { Button } from "@os-copilot/ui/components/ui/button" +``` + +App-local blocks or non-UI components can still live under the app's `#components` alias. + +### UI workspace contract + +`packages/ui/components.json` should use package-local aliases so CLI commands run inside `packages/ui` generate files inside the package: + +```json +{ + "style": "kobalte", + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "#components", + "ui": "#components/ui", + "lib": "#lib", + "hooks": "#hooks", + "utils": "#lib/utils" + }, + "registries": { + "@zaidan": "https://zaidan.carere.dev/r/{style}/{name}.json" + } +} +``` + +The UI package must expose every path that an app-level `components.json` can reference. This means `package.json` needs source-level exports for CLI-time routing, plus build output exports for library consumption if those differ. + +```json +{ + "name": "@os-copilot/ui", + "type": "module", + "imports": { + "#components/*": "./src/components/*.tsx", + "#lib/*": "./src/lib/*.ts", + "#hooks/*": "./src/hooks/*.ts" + }, + "exports": { + "./styles.css": "./src/styles.css", + "./components/*": "./src/components/*.tsx", + "./lib/*": "./src/lib/*.ts", + "./hooks/*": "./src/hooks/*.ts" + } +} +``` + +Implementation must reconcile this shadcn source-export routing with the stricter build output export contract below. If publishing later becomes a goal, source exports should be replaced or complemented with `dist` exports without breaking app-level CLI installs. + +## Component inventory and source plan + +Components below are taken from `repos/arkitect-ui/packages/solid/src/components/ui/*` and classified against confirmed Zaidan Kobalte availability. + +### Install from Zaidan + +These must be installed through the shadcn CLI, then adapted only for local package boundaries, exports, stories, and mobile-first QA. + +```bash +pnpm dlx shadcn@latest add \ + @zaidan/alert-dialog \ + @zaidan/avatar \ + @zaidan/badge \ + @zaidan/button \ + @zaidan/card \ + @zaidan/chart \ + @zaidan/checkbox \ + @zaidan/collapsible \ + @zaidan/combobox \ + @zaidan/dialog \ + @zaidan/dropdown-menu \ + @zaidan/empty \ + @zaidan/input \ + @zaidan/input-group \ + @zaidan/label \ + @zaidan/pagination \ + @zaidan/select \ + @zaidan/separator \ + @zaidan/sheet \ + @zaidan/sidebar \ + @zaidan/skeleton \ + @zaidan/table \ + @zaidan/textarea \ + @zaidan/tooltip +``` + +| Arkitect component | Zaidan CLI item | Notes | +| ------------------ | ----------------------- | ------------------------------------------------------ | +| `alert-dialog` | `@zaidan/alert-dialog` | Install | +| `avatar` | `@zaidan/avatar` | Install | +| `badge` | `@zaidan/badge` | Install | +| `button` | `@zaidan/button` | Install | +| `card` | `@zaidan/card` | Install | +| `chart` | `@zaidan/chart` | Install; validate chart peer dependencies | +| `checkbox` | `@zaidan/checkbox` | Install | +| `collapsible` | `@zaidan/collapsible` | Install | +| `combobox` | `@zaidan/combobox` | Install | +| `dialog` | `@zaidan/dialog` | Install | +| `dropdown-menu` | `@zaidan/dropdown-menu` | Install | +| `empty` | `@zaidan/empty` | Install | +| `input` | `@zaidan/input` | Install | +| `input-group` | `@zaidan/input-group` | Install | +| `label` | `@zaidan/label` | Install | +| `pagination` | `@zaidan/pagination` | Install | +| `select` | `@zaidan/select` | Install | +| `separator` | `@zaidan/separator` | Install | +| `sheet` | `@zaidan/sheet` | Install; mobile drawer behavior is critical | +| `sidebar` | `@zaidan/sidebar` | Install; validate mobile collapsed/off-canvas behavior | +| `skeleton` | `@zaidan/skeleton` | Install | +| `table` | `@zaidan/table` | Install; data-table remains custom | +| `textarea` | `@zaidan/textarea` | Install | +| `tooltip` | `@zaidan/tooltip` | Install | + +### Implement custom in `packages/ui` + +These were present in Arkitect but were not confirmed as Zaidan Kobalte components. Implement them from scratch for this project, with different structure and behavior where useful. + +| Arkitect component | PRD source | Requirement | +| -------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `center` | Custom | Layout primitive for centering content with responsive sizing props. | +| `copy-id-button` | Custom | Mobile-safe copy affordance with accessible feedback and no hard dependency on Arkitect behavior. | +| `data-table` | Custom | Composition over `@zaidan/table`; mobile-first stacked/card fallback for narrow screens. | +| `data-table-filters` | Custom | Filter primitives optimized for touch targets and collapsible mobile controls. | +| `float` | Custom | Positioning/layout primitive; avoid hidden desktop assumptions. | +| `grid` | Custom | Responsive grid primitive with mobile-first defaults. | +| `grid-pattern` | Custom | Decorative background primitive; ensure SSR-safe and low render cost. | +| `marquee` | Custom | Optional motion component respecting reduced-motion preferences. | +| `stack` | Custom | Layout primitive for vertical/horizontal spacing with responsive variants. | +| `toast` | Custom or adapter | Zaidan has `@zaidan/sonner`, not `toast`; decide whether to expose a `toast` adapter over Sonner or implement a local API. | + +## Package architecture + +Proposed file shape: + +```text +apps/web/ + components.json +packages/ui/ + components.json + package.json + project.json + tsconfig.json + tsconfig.lib.json + vite.config.ts + src/ + index.ts + styles.css + lib/ + cn.ts + components/ + ui/ + button.tsx + dialog.tsx + ... + stories/ + ui/ + button.stories.tsx + dialog.stories.tsx + ... + testing/ + smoke-imports.test.ts + .storybook/ + main.ts + preview.ts +``` + +### Export contract + +`packages/ui` must support both ergonomic and granular imports. The package name below is provisional until the workspace naming convention is confirmed. + +```ts +import { Button } from "@os-copilot/ui" +import { Button } from "@os-copilot/ui/button" +import "@os-copilot/ui/styles.css" +``` + +Acceptance requirements: + +- Package is `type: "module"`. +- Build output is ESM-only unless a later consumer requires CJS. +- `solid-js`, `solid-js/web`, Kobalte, Lucide, and other runtime framework libraries are externalized and modeled as peer dependencies where appropriate. +- Root barrel re-exports public components only; no Storybook/test/internal exports. +- Per-component subpath exports exist in `package.json`. +- shadcn monorepo exports also expose `./components/*`, `./lib/*`, `./hooks/*`, and `./styles.css` for CLI routing from the app workspace. +- Generated `.d.ts` files preserve root and subpath imports. +- `sideEffects` is restricted to CSS files, for example `["**/*.css"]`. + +Example `exports` intent: + +```json +{ + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./button": { + "types": "./dist/components/ui/button.d.ts", + "import": "./dist/components/ui/button.js" + }, + "./styles.css": "./dist/styles.css" +} +``` + +## Storybook and Nx compatibility + +- Use Storybook Solid + Vite through `storybook-solidjs-vite` unless Nx adds first-party Solid Storybook support before implementation. +- Install `@nx/storybook` at the same major/minor version as `nx` to avoid version mismatch issues. +- Add `@nx/storybook/plugin` to `nx.json` so Storybook tasks are inferred from project-level `.storybook` config files. +- Treat Nx's Storybook generator as partially compatible: current documented `uiFramework` choices do not include Solid, so `packages/ui/.storybook/*` may need manual setup or a generated baseline followed by Solid-specific replacement. +- Use project-level Storybook config for `packages/ui`; do not start with a root shared Storybook unless multiple packages later need composition. +- Use Storybook Vite `viteFinal` to merge Solid/Vite aliases, Nx path resolution, Tailwind v4 CSS processing, and package workspace exports. +- Stories live outside component source: `src/stories/ui/.stories.tsx`. +- Every public component has at least one default mobile viewport story. +- Interactive components include keyboard and touch-oriented examples where relevant. +- Storybook must load `src/styles.css` and Zaidan theme tokens. +- Required Storybook targets: + - `storybook` + - `build-storybook` + - `test-storybook` if interaction tests are enabled +- Prefer addons for docs and accessibility if dependency cost is acceptable. + +## SSR and SPA compatibility + +The UI package must work in both SSR and SPA consumers. Solid components should render deterministically on the server and hydrate cleanly in the browser. + +Requirements: + +- Components must not read `window`, `document`, `localStorage`, layout measurements, media queries, or element refs during initial render. +- Browser-only behavior must be isolated in `onMount`, event handlers, lazy imports, or explicit client-only wrappers in the consuming app. +- Initial signal values must not branch on `isServer` in a way that changes server HTML vs hydrated client HTML. +- Components that cannot be SSR-safe must be explicitly marked as client-only and excluded from the default SSR acceptance path. +- Random IDs, generated IDs, timestamps, and measurements must be stable between SSR and hydration or delegated to Solid/Kobalte primitives that guarantee stability. +- Overlay/portal components must document SSR expectations and avoid assuming `document.body` exists during server render. +- Custom components need SPA and SSR smoke stories/tests where practical: render-to-string or SSR fixture plus client mount/hydration fixture. + +## CSS, shadcn, Zaidan, and tweakcn compatibility + +The CSS layer must remain compatible with shadcn's Tailwind v4 theming model and with tweakcn-style theme editing. + +Findings: + +- shadcn Tailwind v4 themes are CSS-variable first: semantic variables live in `:root` and `.dark`, then `@theme inline` maps them to Tailwind utilities such as `bg-background`, `text-foreground`, `border-border`, and `ring-ring`. +- Zaidan uses the shadcn registry model: `registry:theme`, `registry:style`, `registry:font`, `cssVars`, `tailwind`, `css`, and registry dependencies. The selected Zaidan setup installs `@zaidan/font-inter`, `@zaidan/neutral`, and `@zaidan/style-vega` into the configured Tailwind CSS file. +- Vega is a shadcn visual style; Zaidan adapts that registry/style model for Solid/Kobalte. +- tweakcn targets shadcn-compatible theme variables and supports Tailwind v4, so this package should preserve standard variable names instead of inventing a parallel token system. + +Requirements: + +- `src/styles.css` is the single source of truth for shadcn/Zaidan/tweakcn theme variables. +- Keep `tailwind.cssVariables: true` in both app and UI `components.json` files. +- Keep Tailwind v4 `tailwind.config` empty in `components.json` unless the project later proves a config file is required. +- Preserve standard shadcn variable names: `--background`, `--foreground`, `--card`, `--popover`, `--primary`, `--secondary`, `--muted`, `--accent`, `--destructive`, `--border`, `--input`, `--ring`, `--chart-*`, `--sidebar-*`, and `--radius`. +- Preserve `@theme inline` mappings for Tailwind utilities and radius derivations. +- Custom component CSS must consume semantic utilities or variables; do not hardcode one-off OKLCH values inside components unless the value is a documented design token. +- tweakcn-exported Tailwind v4 CSS should be pasteable into `src/styles.css` without changing component source. +- Zaidan CLI CSS changes must be reviewed after install because registry items may append CSS, imports, animations, or variables. + +## Mobile-first requirements + +- Default examples and component variants must assume narrow screens first. +- Touch targets should be at least 44px where the component is interactive. +- Overlays (`dialog`, `sheet`, `dropdown-menu`, `combobox`, `select`, `sidebar`) must be usable on mobile viewport sizes. +- Tables must not require horizontal scrolling as the only mobile strategy; `data-table` must provide a stacked/card alternative or explicit responsive mode. +- Motion components must respect `prefers-reduced-motion`. +- Components must support dark mode through tokens, not one-off component overrides. + +## Nx targets and cache + +`project.json` should expose cacheable targets compatible with root `nx.json` defaults: + +- `build`: Vite library build. +- `typecheck`: TypeScript/tsgo typecheck for the library. +- `lint`: oxlint/dprint compatible lint formatting path. +- `test`: Vitest smoke/unit tests where applicable. +- `storybook`: local Storybook, using Nx's default inferred task name where possible. +- `build-storybook`: CI/cacheable static Storybook build. +- `test-storybook`: optional interaction/a11y checks if enabled. + +Build inputs should include source, package config, Storybook config, Vite config, Tailwind/Zaidan config, and dependency lockfiles as appropriate. + +## Acceptance criteria + +### Package setup + +- [ ] `packages/ui` exists and is included by `pnpm-workspace.yaml`. +- [ ] Nx recognizes `ui` as a project with build/typecheck/lint/test/storybook targets. +- [ ] `@nx/storybook` is installed at a version matching `nx` and `@nx/storybook/plugin` is configured if inferred tasks are used. +- [ ] `pnpm nx build ui` produces ESM JS and `.d.ts` output. +- [ ] `pnpm nx typecheck ui` passes. +- [ ] `pnpm nx build-storybook ui` passes. + +### Zaidan and shadcn monorepo install path + +- [ ] `apps/web/components.json` and `packages/ui/components.json` point to the Zaidan Kobalte registry. +- [ ] Both workspaces keep the same `style`, `iconLibrary`, and `baseColor`. +- [ ] App `components.json` routes `ui` to `@os-copilot/ui/components/ui` and `utils` to `@os-copilot/ui/lib/utils`. +- [ ] UI `components.json` uses package-local `#components`, `#lib`, and `#hooks` aliases. +- [ ] `packages/ui/package.json` exports the paths referenced by the app workspace aliases. +- [ ] Running `pnpm dlx shadcn@latest add @zaidan/button` from `apps/web` installs the UI component into `packages/ui` and rewrites imports to `@os-copilot/ui/components/ui/button`. +- [ ] Baseline design packages are installed with `pnpm dlx shadcn@latest add @zaidan/font-inter @zaidan/neutral @zaidan/style-vega`. +- [ ] Confirmed Zaidan components are installed through the CLI, not manually copied from Arkitect. +- [ ] CLI-generated files are normalized to the package's structure and export policy. + +### Tree-shaking + +- [ ] Root barrel import works. +- [ ] Per-component subpath imports work. +- [ ] A smoke test or fixture verifies importing one component does not require importing all components. +- [ ] Package metadata uses `sideEffects` narrowly for CSS only. +- [ ] Peer dependencies are externalized in Vite build. + +### SSR and SPA + +- [ ] Components render in a Solid SSR fixture without `window`/`document` errors. +- [ ] Components mount in a SPA fixture without hydration-only assumptions. +- [ ] Hydration smoke test covers at least one interactive component, one overlay component, and one custom layout component. +- [ ] Any client-only component is documented and not exported as SSR-safe by default. + +### CSS and theming + +- [ ] `src/styles.css` contains shadcn/Zaidan Tailwind v4 variables and `@theme inline` mappings. +- [ ] tweakcn Tailwind v4 theme output can replace or override the variables without component source changes. +- [ ] Custom components use semantic utilities/variables instead of hardcoded colors. +- [ ] Zaidan registry CSS changes are reviewed after CLI install. + +### Storybook + +- [ ] Stories live in `src/stories/ui/*`. +- [ ] Every public component has a story. +- [ ] Mobile viewport is the default review path. +- [ ] Storybook renders with Zaidan theme/style/font tokens. +- [ ] Storybook config uses `storybook-solidjs-vite` or a documented compatible Solid+Vite adapter. +- [ ] Storybook Vite config resolves workspace package exports and app/UI aliases. + +### Custom components + +- [ ] Custom components are implemented from scratch. +- [ ] Custom components have a clear reason for existing because Zaidan does not provide them. +- [ ] Custom components expose mobile-first stories and accessibility notes. +- [ ] `toast` decision is documented: Sonner adapter vs local toast implementation. + +## Open questions + +1. Should the package name be `@os-copilot/ui`, `@repo/ui`, or another workspace convention? + R: @os-copilot/ui +2. Should `toast` become an adapter around `@zaidan/sonner`, or remain a custom component API? + R: adapter from @zaidan/sonner +3. Should layout primitives (`center`, `stack`, `grid`, `float`) live in `components/ui` or a separate `src/layout/*` namespace with exported aliases? + R: is ok live in components/ui +4. Should data-table depend on a table utility such as TanStack Table, or stay as a lightweight composition layer initially? + R: yup Tanstack Table +5. Should Storybook include visual regression tooling in the first milestone or only static build verification? + R: Yes +6. Should client-only exceptions be allowed in the core package, or should all exported components be SSR-safe by default? + R: we need to take a look in this one because i dont understand well the quesion. +7. Should tweakcn be a documented workflow only, or should we add a fixture/theme import test for tweakcn-generated Tailwind v4 CSS? + R: should add fixuter theme import test for. + +## Suggested implementation milestones + +1. **Scaffold package**: create `packages/ui`, Vite/Solid build, Nx targets, TypeScript config, package exports, styles entry. +2. **Configure Zaidan**: add `components.json`, Tailwind v4 styling path, baseline theme/font/style packages. +3. **Install Zaidan components**: add confirmed components with shadcn CLI and normalize exports. +4. **Storybook foundation**: configure `@nx/storybook` plus `storybook-solidjs-vite`, global styles, mobile viewport defaults, stories folder convention. +5. **CSS compatibility**: verify shadcn/Zaidan Tailwind v4 variables, `@theme inline`, dark mode, and tweakcn replacement workflow. +6. **Custom component batch**: implement missing Arkitect-equivalent components from scratch, prioritizing layout primitives and data-table. +7. **SSR/SPA verification**: add render/hydration smoke fixtures and mark any client-only exceptions. +8. **Tree-shaking verification**: add smoke fixture/tests for root and subpath imports, externalized peers, and CSS side effects. diff --git a/nx.json b/nx.json index 39a16b1..c03e6e3 100644 --- a/nx.json +++ b/nx.json @@ -38,6 +38,15 @@ "buildDepsTargetName": "build-deps", "watchDepsTargetName": "watch-deps" } + }, + { + "plugin": "@nx/storybook/plugin", + "options": { + "buildStorybookTargetName": "build-storybook", + "serveStorybookTargetName": "storybook", + "testStorybookTargetName": "test-storybook", + "staticStorybookTargetName": "static-storybook" + } } ], "targetDefaults": { diff --git a/package.json b/package.json index a99df61..6ab48f1 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "@effect/language-service": "catalog:", "@effect/tsgo": "catalog:", "@nx/js": "catalog:", + "@nx/storybook": "catalog:", "@nx/vite": "catalog:", + "@tailwindcss/vite": "catalog:", "@typescript/native-preview": "catalog:", "dprint": "catalog:", "husky": "catalog:", diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts new file mode 100644 index 0000000..2d57b93 --- /dev/null +++ b/packages/ui/.storybook/main.ts @@ -0,0 +1,21 @@ +import tailwindcss from "@tailwindcss/vite" +import type { StorybookConfig } from "storybook-solidjs-vite" +import { mergeConfig } from "vite" + +const config: StorybookConfig = { + stories: ["../src/stories/ui/**/*.stories.@(ts|tsx|mdx)"], + addons: [], + framework: { + name: "storybook-solidjs-vite", + options: {}, + }, + viteFinal: async (viteConfig) => + mergeConfig(viteConfig, { + plugins: [tailwindcss()], + resolve: { + tsconfigPaths: true, + }, + }), +} + +export default config diff --git a/packages/ui/.storybook/preview.ts b/packages/ui/.storybook/preview.ts new file mode 100644 index 0000000..84490a0 --- /dev/null +++ b/packages/ui/.storybook/preview.ts @@ -0,0 +1,15 @@ +import type { Preview } from "storybook-solidjs-vite" + +// oxlint-disable-next-line import/no-unassigned-import -- Storybook preview applies global UI styles. +import "../src/styles.css" + +const preview: Preview = { + parameters: { + viewport: { + defaultViewport: "mobile1", + }, + layout: "centered", + }, +} + +export default preview diff --git a/packages/ui/components.json b/packages/ui/components.json new file mode 100644 index 0000000..9f089f7 --- /dev/null +++ b/packages/ui/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "kobalte", + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "#components", + "ui": "#components/ui", + "lib": "#lib", + "hooks": "#hooks", + "utils": "#lib/utils" + }, + "registries": { + "@zaidan": "https://zaidan.carere.dev/r/{style}/{name}.json" + } +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..69e0acb --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,208 @@ +{ + "name": "@os-copilot/ui", + "version": "0.0.0", + "private": true, + "type": "module", + "sideEffects": [ + "**/*.css" + ], + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./alert-dialog": { + "types": "./src/components/ui/alert-dialog.tsx", + "import": "./src/components/ui/alert-dialog.tsx" + }, + "./avatar": { + "types": "./src/components/ui/avatar.tsx", + "import": "./src/components/ui/avatar.tsx" + }, + "./badge": { + "types": "./src/components/ui/badge.tsx", + "import": "./src/components/ui/badge.tsx" + }, + "./button": { + "types": "./src/components/ui/button.tsx", + "import": "./src/components/ui/button.tsx" + }, + "./card": { + "types": "./src/components/ui/card.tsx", + "import": "./src/components/ui/card.tsx" + }, + "./center": { + "types": "./src/components/ui/center.tsx", + "import": "./src/components/ui/center.tsx" + }, + "./chart": { + "types": "./src/components/ui/chart.tsx", + "import": "./src/components/ui/chart.tsx" + }, + "./checkbox": { + "types": "./src/components/ui/checkbox.tsx", + "import": "./src/components/ui/checkbox.tsx" + }, + "./collapsible": { + "types": "./src/components/ui/collapsible.tsx", + "import": "./src/components/ui/collapsible.tsx" + }, + "./combobox": { + "types": "./src/components/ui/combobox.tsx", + "import": "./src/components/ui/combobox.tsx" + }, + "./copy-id-button": { + "types": "./src/components/ui/copy-id-button.tsx", + "import": "./src/components/ui/copy-id-button.tsx" + }, + "./data-table": { + "types": "./src/components/ui/data-table.tsx", + "import": "./src/components/ui/data-table.tsx" + }, + "./data-table-filters": { + "types": "./src/components/ui/data-table-filters.tsx", + "import": "./src/components/ui/data-table-filters.tsx" + }, + "./dialog": { + "types": "./src/components/ui/dialog.tsx", + "import": "./src/components/ui/dialog.tsx" + }, + "./dropdown-menu": { + "types": "./src/components/ui/dropdown-menu.tsx", + "import": "./src/components/ui/dropdown-menu.tsx" + }, + "./empty": { + "types": "./src/components/ui/empty.tsx", + "import": "./src/components/ui/empty.tsx" + }, + "./float": { + "types": "./src/components/ui/float.tsx", + "import": "./src/components/ui/float.tsx" + }, + "./grid": { + "types": "./src/components/ui/grid.tsx", + "import": "./src/components/ui/grid.tsx" + }, + "./grid-pattern": { + "types": "./src/components/ui/grid-pattern.tsx", + "import": "./src/components/ui/grid-pattern.tsx" + }, + "./input": { + "types": "./src/components/ui/input.tsx", + "import": "./src/components/ui/input.tsx" + }, + "./input-group": { + "types": "./src/components/ui/input-group.tsx", + "import": "./src/components/ui/input-group.tsx" + }, + "./label": { + "types": "./src/components/ui/label.tsx", + "import": "./src/components/ui/label.tsx" + }, + "./marquee": { + "types": "./src/components/ui/marquee.tsx", + "import": "./src/components/ui/marquee.tsx" + }, + "./pagination": { + "types": "./src/components/ui/pagination.tsx", + "import": "./src/components/ui/pagination.tsx" + }, + "./select": { + "types": "./src/components/ui/select.tsx", + "import": "./src/components/ui/select.tsx" + }, + "./separator": { + "types": "./src/components/ui/separator.tsx", + "import": "./src/components/ui/separator.tsx" + }, + "./sheet": { + "types": "./src/components/ui/sheet.tsx", + "import": "./src/components/ui/sheet.tsx" + }, + "./sidebar": { + "types": "./src/components/ui/sidebar.tsx", + "import": "./src/components/ui/sidebar.tsx" + }, + "./skeleton": { + "types": "./src/components/ui/skeleton.tsx", + "import": "./src/components/ui/skeleton.tsx" + }, + "./stack": { + "types": "./src/components/ui/stack.tsx", + "import": "./src/components/ui/stack.tsx" + }, + "./table": { + "types": "./src/components/ui/table.tsx", + "import": "./src/components/ui/table.tsx" + }, + "./textarea": { + "types": "./src/components/ui/textarea.tsx", + "import": "./src/components/ui/textarea.tsx" + }, + "./toast": { + "types": "./src/components/ui/toast.tsx", + "import": "./src/components/ui/toast.tsx" + }, + "./tooltip": { + "types": "./src/components/ui/tooltip.tsx", + "import": "./src/components/ui/tooltip.tsx" + }, + "./styles.css": "./src/styles.css", + "./components/ui/*": { + "types": "./src/components/ui/*.tsx", + "import": "./src/components/ui/*.tsx" + }, + "./components/*": { + "types": "./src/components/*.tsx", + "import": "./src/components/*.tsx" + }, + "./lib/*": { + "types": "./src/lib/*.ts", + "import": "./src/lib/*.ts" + }, + "./hooks/*": { + "types": "./src/hooks/*.ts", + "import": "./src/hooks/*.ts" + } + }, + "imports": { + "#components/*": "./src/components/*.tsx", + "#lib/*": "./src/lib/*.ts", + "#hooks/*": "./src/hooks/*.ts" + }, + "scripts": { + "build": "vite build && tsgo -p tsconfig.lib.json", + "typecheck": "tsgo --noEmit -p tsconfig.json", + "test": "vitest run --passWithNoTests", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" + }, + "peerDependencies": { + "solid-js": "catalog:" + }, + "devDependencies": { + "@effect/tsgo": "catalog:", + "@testing-library/jest-dom": "catalog:", + "jsdom": "catalog:", + "solid-js": "catalog:", + "storybook": "catalog:", + "storybook-solidjs-vite": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-solid": "catalog:", + "vitest": "catalog:" + }, + "dependencies": { + "@fontsource-variable/inter": "catalog:", + "@kobalte/core": "catalog:", + "@tanstack/solid-table": "catalog:", + "class-variance-authority": "catalog:", + "clsx": "catalog:", + "echarts": "catalog:", + "lucide-solid": "catalog:", + "solid-sonner": "catalog:", + "tailwind-merge": "catalog:", + "tailwindcss": "catalog:", + "tw-animate-css": "catalog:" + } +} diff --git a/packages/ui/project.json b/packages/ui/project.json new file mode 100644 index 0000000..8fda2da --- /dev/null +++ b/packages/ui/project.json @@ -0,0 +1,52 @@ +{ + "name": "ui", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "packages/ui/src", + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "cwd": "packages/ui", + "command": "pnpm build" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "cwd": "packages/ui", + "command": "pnpm typecheck" + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "cwd": "packages/ui", + "command": "pnpm test" + } + }, + "storybook": { + "executor": "nx:run-commands", + "options": { + "cwd": "packages/ui", + "command": "pnpm storybook" + } + }, + "build-storybook": { + "executor": "nx:run-commands", + "options": { + "cwd": "packages/ui", + "command": "pnpm build-storybook" + } + }, + "lint": { + "executor": "nx-oxlint:lint", + "options": { + "configFile": ".oxlintrc.json", + "lintFilePatterns": ["packages/ui/**/*.{ts,tsx,js,jsx,mts,cts}"] + }, + "outputs": ["{options.outputFile}"] + } + }, + "tags": ["scope:ui", "type:lib"] +} diff --git a/packages/ui/src/components/color-mode.tsx b/packages/ui/src/components/color-mode.tsx new file mode 100644 index 0000000..2ec9460 --- /dev/null +++ b/packages/ui/src/components/color-mode.tsx @@ -0,0 +1,55 @@ +import { type Accessor, createContext, createSignal, type ParentProps, untrack, useContext } from "solid-js" + +export const ZAIDAN_COLOR_MODE_COOKIE_KEY = "zaidan-color-mode" + +export type ColorMode = "light" | "dark" + +export type ColorModeContextValue = { + colorMode: Accessor + toggleColorMode: () => void + setColorMode: (mode: ColorMode) => void +} + +export const ColorModeContext = createContext() + +export function ColorModeProvider( + props: ParentProps<{ + initialColorMode: ColorMode + }>, +) { + const [colorMode, setColorMode] = createSignal(props.initialColorMode) + + const toggleColorMode = () => { + setColorMode((prev) => (prev === "dark" ? "light" : "dark")) + + // Update the HTML element class + const html = document.documentElement + html.classList.remove("light", "dark") + html.classList.add(untrack(colorMode)) + + // Set the cookie + // biome-ignore lint/suspicious/noDocumentCookie: + document.cookie = `${ZAIDAN_COLOR_MODE_COOKIE_KEY}=${untrack(colorMode)}; path=/; max-age=31536000; SameSite=Lax` + } + + return ( + + {props.children} + + ) +} + +export function useColorMode(): ColorModeContextValue { + const context = useContext(ColorModeContext) + if (context === undefined) { + throw new Error("useColorMode must be used within a ColorModeProvider") + } + return context +} + +export const getClientColorMode = () => + document.cookie + .split("; ") + .find((cookie) => cookie.startsWith(`${ZAIDAN_COLOR_MODE_COOKIE_KEY}=`)) + ?.split("=")[1] ?? + (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") diff --git a/packages/ui/src/components/ui/alert-dialog.tsx b/packages/ui/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..b28bed1 --- /dev/null +++ b/packages/ui/src/components/ui/alert-dialog.tsx @@ -0,0 +1,226 @@ +import * as AlertDialogPrimitive from "@kobalte/core/alert-dialog" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" +import type { Component, ComponentProps, ValidComponent } from "solid-js" +import { mergeProps, splitProps } from "solid-js" + +import { cn } from "#lib/utils" +import { Button, type ButtonProps } from "#components/ui/button" + +const AlertDialog: Component = (props) => { + return +} + +type AlertDialogTriggerProps = PolymorphicProps< + T, + AlertDialogPrimitive.AlertDialogTriggerProps +> + +const AlertDialogTrigger = ( + props: AlertDialogTriggerProps, +) => { + return +} + +const AlertDialogPortal: Component = (props) => { + return +} + +type AlertDialogOverlayProps = + & PolymorphicProps< + T, + AlertDialogPrimitive.AlertDialogOverlayProps + > + & Pick, "class"> + +const AlertDialogOverlay = ( + props: AlertDialogOverlayProps, +) => { + const [local, others] = splitProps(props as AlertDialogOverlayProps, ["class"]) + return ( + + ) +} + +type AlertDialogContentProps = + & PolymorphicProps< + T, + AlertDialogPrimitive.AlertDialogContentProps + > + & Pick, "class"> + & { size?: "default" | "sm" } + +const AlertDialogContent = ( + props: AlertDialogContentProps, +) => { + const mergedProps = mergeProps({ size: "default" } as AlertDialogContentProps, props) + const [local, others] = splitProps(mergedProps, ["class", "size"]) + return ( + + + + + ) +} + +type AlertDialogHeaderProps = ComponentProps<"div"> + +const AlertDialogHeader = (props: AlertDialogHeaderProps) => { + const [local, others] = splitProps(props, ["class"]) + return ( +
+ ) +} + +type AlertDialogFooterProps = ComponentProps<"div"> + +const AlertDialogFooter = (props: AlertDialogFooterProps) => { + const [local, others] = splitProps(props, ["class"]) + return ( +
+ ) +} + +type AlertDialogMediaProps = ComponentProps<"div"> + +const AlertDialogMedia = (props: AlertDialogMediaProps) => { + const [local, others] = splitProps(props, ["class"]) + return ( +
+ ) +} + +type AlertDialogTitleProps = + & PolymorphicProps< + T, + AlertDialogPrimitive.AlertDialogTitleProps + > + & Pick, "class"> + +const AlertDialogTitle = (props: AlertDialogTitleProps) => { + const [local, others] = splitProps(props as AlertDialogTitleProps, ["class"]) + return ( + + ) +} + +type AlertDialogDescriptionProps = + & PolymorphicProps< + T, + AlertDialogPrimitive.AlertDialogDescriptionProps + > + & Pick, "class"> + +const AlertDialogDescription = ( + props: AlertDialogDescriptionProps, +) => { + const [local, others] = splitProps(props as AlertDialogDescriptionProps, ["class"]) + return ( + + ) +} + +type AlertDialogActionProps = + & PolymorphicProps< + T, + AlertDialogPrimitive.AlertDialogCloseButtonProps + > + & Pick + +const AlertDialogAction = ( + props: AlertDialogActionProps, +) => { + const mergedProps = mergeProps({ variant: "default", size: "default" }, props) + const [local, others] = splitProps(mergedProps as AlertDialogActionProps, [ + "class", + "variant", + "size", + ]) + return ( + + ) +} + +type AlertDialogCancelProps = + & PolymorphicProps< + T, + AlertDialogPrimitive.AlertDialogCloseButtonProps + > + & Pick + +const AlertDialogCancel = ( + props: AlertDialogCancelProps, +) => { + const mergedProps = mergeProps({ variant: "outline", size: "default" }, props) + const [local, others] = splitProps(mergedProps as AlertDialogCancelProps, [ + "class", + "variant", + "size", + ]) + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/packages/ui/src/components/ui/avatar.tsx b/packages/ui/src/components/ui/avatar.tsx new file mode 100644 index 0000000..cc63a1e --- /dev/null +++ b/packages/ui/src/components/ui/avatar.tsx @@ -0,0 +1,125 @@ +import * as ImagePrimitive from "@kobalte/core/image" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" +import type { ComponentProps, ValidComponent } from "solid-js" +import { mergeProps, splitProps } from "solid-js" + +import { cn } from "#lib/utils" + +type AvatarRootProps = + & PolymorphicProps< + T, + ImagePrimitive.ImageRootProps + > + & Pick, "class"> + & { + size?: "sm" | "default" | "lg" + } + +const Avatar = (props: AvatarRootProps) => { + const mergedProps = mergeProps({ size: "default" }, props) + const [local, others] = splitProps(mergedProps as AvatarRootProps, ["class", "size"]) + return ( + + ) +} + +type AvatarImageProps = + & PolymorphicProps< + T, + ImagePrimitive.ImageImgProps + > + & Pick, "class"> + +const AvatarImage = (props: AvatarImageProps) => { + const [local, others] = splitProps(props as AvatarImageProps, ["class"]) + return ( + + ) +} + +type AvatarFallbackProps = + & PolymorphicProps< + T, + ImagePrimitive.ImageFallbackProps + > + & Pick, "class"> + +const AvatarFallback = (props: AvatarFallbackProps) => { + const [local, others] = splitProps(props as AvatarFallbackProps, ["class"]) + return ( + + ) +} + +type AvatarBadgeProps = ComponentProps<"span"> + +function AvatarBadge(props: AvatarBadgeProps) { + const [local, others] = splitProps(props as AvatarBadgeProps, ["class"]) + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + local.class, + )} + {...others} + /> + ) +} + +type AvatarGroupProps = ComponentProps<"div"> + +function AvatarGroup(props: AvatarGroupProps) { + const [local, others] = splitProps(props as AvatarGroupProps, ["class"]) + return ( +
+ ) +} + +type AvatarGroupCountProps = ComponentProps<"div"> + +function AvatarGroupCount(props: AvatarGroupCountProps) { + const [local, others] = splitProps(props as AvatarGroupCountProps, ["class"]) + return ( +
+ ) +} + +export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } diff --git a/packages/ui/src/components/ui/badge.tsx b/packages/ui/src/components/ui/badge.tsx new file mode 100644 index 0000000..4ebc0e4 --- /dev/null +++ b/packages/ui/src/components/ui/badge.tsx @@ -0,0 +1,42 @@ +import { type BadgeRootProps, Root } from "@kobalte/core/badge" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" +import { cva, type VariantProps } from "class-variance-authority" +import { splitProps, type ValidComponent } from "solid-js" +import { cn } from "#lib/utils" + +const badgeVariants = cva( + "group/badge z-badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none", + { + variants: { + variant: { + default: "z-badge-variant-default", + secondary: "z-badge-variant-secondary", + destructive: "z-badge-variant-destructive", + outline: "z-badge-variant-outline", + ghost: "z-badge-variant-ghost", + link: "z-badge-variant-link", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +) + +type BadgeProps = + & PolymorphicProps> + & VariantProps + +const Badge = (props: BadgeProps) => { + const [local, others] = splitProps(props as BadgeProps, ["class", "variant"]) + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx new file mode 100644 index 0000000..150b478 --- /dev/null +++ b/packages/ui/src/components/ui/button.tsx @@ -0,0 +1,53 @@ +import { type ButtonRootProps, Root } from "@kobalte/core/button" +import type { PolymorphicProps } from "@kobalte/core/polymorphic" +import { cva, type VariantProps } from "class-variance-authority" +import { type ComponentProps, splitProps, type ValidComponent } from "solid-js" +import { cn } from "#lib/utils" + +const buttonVariants = cva( + "group/button z-button inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap outline-none transition-all active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "z-button-variant-default", + outline: "z-button-variant-outline", + secondary: "z-button-variant-secondary", + ghost: "z-button-variant-ghost", + destructive: "z-button-variant-destructive", + link: "z-button-variant-link", + }, + size: { + default: "z-button-size-default", + xs: "z-button-size-xs", + sm: "z-button-size-sm", + lg: "z-button-size-lg", + icon: "z-button-size-icon", + "icon-xs": "z-button-size-icon-xs", + "icon-sm": "z-button-size-icon-sm", + "icon-lg": "z-button-size-icon-lg", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +type ButtonProps = + & PolymorphicProps> + & VariantProps + & Pick, "class"> + +const Button = (props: ButtonProps) => { + const [local, others] = splitProps(props as ButtonProps, ["variant", "size", "class"]) + return ( + + ) +} + +export { Button, type ButtonProps, buttonVariants } diff --git a/packages/ui/src/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx new file mode 100644 index 0000000..0c30ee4 --- /dev/null +++ b/packages/ui/src/components/ui/card.tsx @@ -0,0 +1,91 @@ +import { type ComponentProps, mergeProps, splitProps } from "solid-js" +import { cn } from "#lib/utils" + +type CardProps = ComponentProps<"div"> & { size?: "default" | "sm" } + +const Card = (props: CardProps) => { + const mergedProps = mergeProps({ size: "default" } as const, props) + const [local, others] = splitProps(mergedProps, ["class", "size"]) + return ( +
+ ) +} + +type CardHeaderProps = ComponentProps<"div"> + +const CardHeader = (props: CardHeaderProps) => { + const [local, others] = splitProps(props, ["class"]) + return ( +
+ ) +} + +type CardTitleProps = ComponentProps<"div"> + +const CardTitle = (props: CardTitleProps) => { + const [local, others] = splitProps(props, ["class"]) + return ( +
+ ) +} + +type CardDescriptionProps = ComponentProps<"div"> + +const CardDescription = (props: CardDescriptionProps) => { + const [local, others] = splitProps(props, ["class"]) + return
+} + +type CardActionProps = ComponentProps<"div"> + +const CardAction = (props: CardActionProps) => { + const [local, others] = splitProps(props, ["class"]) + return ( +
+ ) +} + +type CardContentProps = ComponentProps<"div"> + +const CardContent = (props: CardContentProps) => { + const [local, others] = splitProps(props, ["class"]) + return
+} + +type CardFooterProps = ComponentProps<"div"> + +const CardFooter = (props: CardFooterProps) => { + const [local, others] = splitProps(props, ["class"]) + return ( +