- TypeScript 90%
- CSS 9.3%
- JavaScript 0.4%
- Shell 0.3%
|
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Reviewed-on: #5 |
||
|---|---|---|
| .storybook | ||
| docs/superpowers | ||
| examples/demo | ||
| scripts | ||
| src | ||
| tests/fixtures | ||
| .gitignore | ||
| .npmrc | ||
| .woodpecker.yml | ||
| CHANGELOG.md | ||
| eslint.config.js | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| renovate.json | ||
| tsconfig.json | ||
| tsdown.config.ts | ||
| vitest.config.ts | ||
| vitest.setup.ts | ||
@azsiaz/ui
Design system React 19 (tokens, thèmes, primitives, shell dashboard) porté du dashboard Dokusho. ESM-only, Tailwind CSS v4 en CSS-first, primitives accessibles Base UI.
- peerDependencies :
react ^19,react-dom ^19,tailwindcss ^4,lucide-react. - Publié sur le registre Forgejo :
https://forge.netserv.fr/api/packages/AzSiAz/npm/.
Installation
pnpm add @azsiaz/ui
pnpm add react react-dom tailwindcss lucide-react
Configurer le registre du scope @azsiaz (dans .npmrc) :
@azsiaz:registry=https://forge.netserv.fr/api/packages/AzSiAz/npm/
Intégration CSS (3 lignes)
Dans la feuille CSS principale de l'app :
@import "tailwindcss";
@import "@azsiaz/ui/styles.css"; /* tokens globaux + base + thème "default" */
@source "../node_modules/@azsiaz/ui/dist"; /* scanne les classes de la lib (ciblé, jamais tout node_modules) */
@import "tailwindcss"doit venir en premier :styles.cssfournit des directives (@custom-variant,@theme,@utility) qui se résolvent dans ton graphe Tailwind.@sourcedoit cibler uniquement@azsiaz/ui/dist(ne jamais scanner toutnode_modules).tailwindcssdoit être la même version majeure v4 dans l'app et la lib (sinon purge des styles).
Thèmes opt-in
Le thème default est inclus dans styles.css. Les autres sont des fichiers importables à la carte :
@import "@azsiaz/ui/themes/old-manga.css";
@import "@azsiaz/ui/themes/command-center.css";
Le thème command-center utilise la police JetBrains Mono : charge-la toi-même (webfont ou @fontsource/jetbrains-mono), la lib ne fait aucune requête de police distante.
Runtime : provider, hook, anti-FOUC
Enrober l'app avec ThemeProvider et piloter le thème via useTheme() :
import { ThemeProvider, useTheme } from '@azsiaz/ui'
function Root() {
return (
<ThemeProvider>
<App />
</ThemeProvider>
)
}
function ThemeSwitcher() {
const { theme, themes, setTheme, isDark, toggleDark } = useTheme()
return (
<>
<select value={theme} onChange={e => setTheme(e.target.value)}>
{themes.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<button onClick={toggleDark}>{isDark ? 'Light' : 'Dark'}</button>
</>
)
}
useTheme() expose : theme, themes, setTheme(id), colorMode (light|dark|system), setColorMode(mode), isDark, toggleDark(). Persistance localStorage (azsiaz-theme, azsiaz-color-mode), écoute de prefers-color-scheme en mode system. Le switch est du pur CSS (attribut data-theme + classe dark sur <html>), sans re-render de l'arbre.
Anti-FOUC (évite le flash de thème)
Injecter les scripts inline avant le premier rendu, dans le <head> (ex. Next app/layout.tsx, ou index.html) :
import { themeScript, sidebarScript } from '@azsiaz/ui/theme-script'
// dans <head>
<script dangerouslySetInnerHTML={{ __html: themeScript() }} />
<script dangerouslySetInnerHTML={{ __html: sidebarScript() }} />
themeScript(options?)accepte{ themeStorageKey, modeStorageKey, defaultTheme }(mêmes défauts que le provider) — évite le flash de thème (mauvaise couleur au premier paint).sidebarScript(options?)accepte{ storageKey }(défautazsiaz-sidebar-collapsed, aligné surSidebarProvider) — évite le flash sidebar dépliée→repliée en posantdata-sidebar-collapsedsur<html>avant le premier rendu.
Shell dashboard
AppProvider est la racine unique (équivalent de <UApp> Nuxt) : il compose thème + toasts + tooltips + sidebar, et expose des passthroughs theme / sidebar / toast. Les briques de shell (AppSidebar, SidebarNav, SidebarFooter, SidebarDrawer, AppTabBar, DashboardPanel, PageHeader) s'assemblent dessous et lisent l'état via useSidebar().
import {
AppProvider, useSidebar,
AppSidebar, SidebarNav, SidebarFooter, SidebarDrawer, AppTabBar,
DashboardPanel, PageHeader,
type NavItem,
} from '@azsiaz/ui'
import { Home, BookOpen, Briefcase } from 'lucide-react'
const nav: NavItem[] = [
{ label: 'Overview', icon: <Home />, href: '/', active: true },
{ label: 'Series', icon: <BookOpen />, href: '/series' },
{ label: 'Jobs', icon: <Briefcase />, href: '/jobs', badge: 4 },
]
const user = { name: 'Ada Lovelace', email: 'ada@example.com' }
// La nav desktop suit le rail replié : on passe `collapsed` explicitement
// (SidebarNav ne le lit pas en interne) pour que la nav du SidebarDrawer mobile
// reste dépliée pendant que le rail desktop se replie.
function DesktopNav() {
const { collapsed } = useSidebar()
return <SidebarNav items={nav} collapsed={collapsed} />
}
export function App() {
return (
<AppProvider sidebar={{ defaultCollapsed: false }}>
<div className="fixed inset-0 flex overflow-hidden bg-[var(--ui-bg)]">
<AppSidebar
collapseControl
header={<span className="sidebar-collapsible-label whitespace-nowrap font-semibold">Tsundoku</span>}
footer={<SidebarFooter user={user} userInitials="AL" onSignOut={() => {}} showCollapseToggle={false} />}
>
<DesktopNav />
</AppSidebar>
{/* Drawer mobile : nav toujours dépliée (pas de prop collapsed). */}
<SidebarDrawer
header={<span className="font-semibold">Tsundoku</span>}
footer={<SidebarFooter user={user} userInitials="AL" showCollapseToggle={false} />}
>
<SidebarNav items={nav} />
</SidebarDrawer>
<main className="flex-1 flex flex-col min-h-0 ml-[var(--sidebar-current-width)] max-md:ml-0">
<DashboardPanel header={<PageHeader title="Series" description="Manage your tracked series" />}>
<div className="p-4">Contenu de la page.</div>
</DashboardPanel>
</main>
</div>
</AppProvider>
)
}
Un seul AppProvider à la racine suffit ; useSidebar() (collapsed/toggle, mobileOpen/open/close) est disponible partout dessous.
Le contrôle compact d'AppSidebar est opt-in : collapseControl le place au milieu du bord extérieur du header, tandis que collapseControl={{ placement: 'header-separator' }} le place à cheval sur le séparateur entre le header et la navigation. L'option render(state, props) permet de remplacer le bouton en conservant son placement, son libellé accessible et son comportement. Si SidebarFooter est utilisé en même temps, passer showCollapseToggle={false} évite d'afficher les deux contrôles.
AppTabBar réutilise le modèle NavItem de SidebarNav et son pattern render router-agnostique. Elle affiche par défaut une navigation basse fixe sous 768px, avec aria-current="page", badges et prise en compte de la safe area. Prévoir sous le contenu scrollable un espace de calc(var(--app-tab-bar-height) + env(safe-area-inset-bottom)) pour qu'il ne passe pas derrière la barre.
<AppTabBar
items={nav.slice(0, 5)}
render={(item, { href: _href, ...props }) => <RouterLink to={item.href!} {...props} />}
/>
DataTable
DataTable— table déclarative : tri client/serveur (sortable,manualSorting), sélection parSet<string>, virtualisation opt-in (virtual+maxHeight, TanStack Virtual),stickyHeader,onRowClick, escape hatchtableRefvers l'instance TanStack Table. La pagination reste externe (composantPagination).
CommandPalette + raccourcis
CommandPalette est une palette contrôlée (open / onOpenChange). Le pattern de montage typique lie l'ouverture à un raccourci global via useHotkey, puis passe des groups déclaratifs (ou items à plat) ; chaque item porte un onSelect, un shortcut optionnel (rendu en Kbd, ex. 'mod+K' → ⌘K) et ferme la palette après sélection sauf si closeOnSelect={false}. useHotkey accepte un chord mod+… (mod = ⌘ sur macOS, Ctrl ailleurs) ou une séquence vim-style ('g d'), avec les options { enabled, ignoreInputs }.
import { CommandPalette, useHotkey } from '@azsiaz/ui'
import { useState } from 'react'
function Palette() {
const [open, setOpen] = useState(false)
useHotkey('mod+k', () => setOpen(true))
return (
<CommandPalette
open={open}
onOpenChange={setOpen}
groups={[
{
heading: 'Navigation',
items: [
{ id: 'series', label: 'Aller aux séries', shortcut: 'g s', onSelect: () => {} },
],
},
]}
/>
)
}
FlowGraph
FlowGraph est un visualiseur de DAG contrôlé : l'app récupère le JSON de tracking pg-jobs et le convertit via fromPgJobsFlow(nodes, edges) (les champs surnuméraires sont ignorés, on_failure: ignore → arête pointillée). Les fan-outs se replient automatiquement en super-nœuds (groupThreshold, défaut 8 ; override par groupKey sur les nœuds). Pan/zoom/clavier sont intégrés ; selectedId + onNodeClick(id) gèrent la sélection, animate active les transitions et fitPadding règle la marge du recadrage (16px par défaut). Le DOM est plafonné à 300 nœuds rendus (MAX_RENDERED_NODES) ; le dimensionnement est à la charge du consommateur (via className). Les fonctions layoutFlowGraph / groupFlowGraph sont exportées pour un usage avancé.
import { FlowGraph, fromPgJobsFlow } from '@azsiaz/ui'
function Pipeline({ tracking }: { tracking: { nodes: PgFlowNode[], edges: PgFlowEdge[] } }) {
const { nodes, edges } = fromPgJobsFlow(tracking.nodes, tracking.edges)
const [selected, setSelected] = useState<string | null>(null)
return (
<FlowGraph
className="h-96"
nodes={nodes}
edges={edges}
selectedId={selected}
onNodeClick={setSelected}
animate
/>
)
}
Contrat de tokens (créer un thème custom)
Un thème = un fichier CSS définissant deux blocs : [data-theme="<id>"] (clair) et [data-theme="<id>"].dark (sombre). Chaque bloc DOIT définir, en oklch :
| Groupe | Variables |
|---|---|
| Surfaces | --ui-bg, --ui-bg-elevated, --ui-bg-muted |
| Bordures | --ui-border, --ui-border-muted |
| Texte | --ui-text, --ui-text-muted, --ui-text-dimmed |
| Sémantiques | --ui-primary, --ui-success, --ui-warning, --ui-error, --ui-info |
| Custom | --color-purple, --color-cyan, --color-orange |
| Soft (15 %) | --ui-<name>-soft et --color-<name>-soft |
| Optionnels | --font-body, --font-mono, --radius-panel, --radius-card |
Les variantes soft se calculent en color-mix :
--ui-primary-soft: color-mix(in oklch, var(--ui-primary) 15%, transparent);
Un thème peut hériter des sémantiques du thème default en ne redéfinissant que surfaces/bordures/texte (cas de old-manga), ou tout surcharger (cas de command-center : primary cyan, mono, radii réduits).
Format oklch
oklch(lightness chroma hue) — lightness 0→1 (ou en %), chroma 0→~0.4, hue 0→360.
Repères de teinte : thèmes chauds hue 60-100, froids 200-280, neutres chroma < 0.01.
Vérifications d'un thème
- Les cartes (
bg-elevated) se distinguent du fond (bg). - Le texte est lisible en clair et sombre.
- Les bordures sont visibles mais discrètes.
- Les états hover (
bg-muted) sont perceptibles.
Développement
pnpm install
pnpm test # vitest run (jsdom + testing-library + axe)
pnpm lint # eslint
pnpm typecheck # tsc --noEmit
pnpm build # tsdown (esm, unbundle, dts) + copie des CSS dans dist/
pnpm storybook # catalogue Storybook 9 (toolbar thème x mode, addon a11y)
pnpm check:packaging # publint + @arethetypeswrong/cli
Publication
Publication automatique, pilotée par la version (.woodpecker.yml, step publish, garde event: push sur branch: main) :
- Bump la version dans
package.json(patch/minor/major) sur une branche, ouvre une PR — la CI joue le workflowcheck(lint → typecheck → test → build →check:packaging). - Une fois mergée sur
main, le steppublishcomparepackage.json.versionà la version publiée sur le registre Forgejo (npm view … --registry https://forge.netserv.fr/api/packages/AzSiAz/npm/). Si elles diffèrent (ou premier publish, oùnpm viewéchoue → traité comme vide), ilpnpm publish --no-git-checkspuis pousse le tagvX.Y.Z; sinon il ne fait rien.
Secrets Woodpecker requis sur le dépôt :
npm_token— jeton Forgejo scopewrite:package(écrit.npmrcavec//forge.netserv.fr/api/packages/AzSiAz/npm/:_authToken=…).forge_git_token— jeton scopewrite:repository, pour pousser le tag de release.
La logique de décision vit dans scripts/version-compare.sh (pure, testée par scripts/version-compare.test.sh) et scripts/should-publish.sh (interroge le registre). L'app examples/demo/ valide de bout en bout l'intégration @source (les classes de la lib sont générées dans le CSS du consommateur) ; son pnpm build échoue si @source ne scanne pas le dist/ publié.