No description
  • TypeScript 90%
  • CSS 9.3%
  • JavaScript 0.4%
  • Shell 0.3%
Find a file
2026-07-18 14:08:12 +00:00
.storybook chore(storybook): Storybook 9 setup, a11y, theme x mode toolbar, tokens story 2026-07-05 20:05:29 +02:00
docs/superpowers docs(plans): v0.2 expansion — lot A primitives, lot B DataTable v2, lot C palette, lot D FlowGraph 2026-07-06 20:49:19 +02:00
examples/demo fix: wire collapsed into SidebarNav in canonical examples + document shell/sidebarScript 2026-07-06 10:32:51 +02:00
scripts ci(publish): version-gated Woodpecker publish to Forgejo npm registry with tag push 2026-07-06 10:23:22 +02:00
src feat(sidebar): add compact collapse control 2026-07-10 16:53:26 +02:00
tests/fixtures feat(styles): port old-manga and command-center themes 2026-07-05 19:43:15 +02:00
.gitignore fix(foundations): pin pnpm, harden ThemeProvider, drop dead dist-probe, assert tailwind at-rules 2026-07-05 20:36:05 +02:00
.npmrc chore: scaffold @azsiaz/ui toolchain (pnpm, ts, eslint, vitest) 2026-07-05 19:19:54 +02:00
.woodpecker.yml ci: CI-friendly vitest timeouts, capped workers, unique nav keys 2026-07-06 10:50:45 +02:00
CHANGELOG.md feat(sidebar): add compact collapse control 2026-07-10 16:53:26 +02:00
eslint.config.js chore: ignore nested build dirs in lint, document version-gated publish 2026-07-06 10:23:22 +02:00
package.json feat(sidebar): add compact collapse control 2026-07-10 16:53:26 +02:00
pnpm-lock.yaml chore(deps): update dependency marked to v18.0.6 2026-07-15 10:28:53 +00:00
pnpm-workspace.yaml chore(storybook): Storybook 9 setup, a11y, theme x mode toolbar, tokens story 2026-07-05 20:05:29 +02:00
README.md feat(sidebar): add compact collapse control 2026-07-10 16:53:26 +02:00
renovate.json Add renovate.json 2026-07-14 18:05:09 +00:00
tsconfig.json feat(button): add Button primitive 2026-07-05 20:43:24 +02:00
tsdown.config.ts feat(markdown): port sanitized markdown renderer as separate entry point 2026-07-06 02:16:32 +02:00
vitest.config.ts ci: CI-friendly vitest timeouts, capped workers, unique nav keys 2026-07-06 10:50:45 +02:00
vitest.setup.ts feat(command-palette): compound structure — cmdk styled with DS tokens in Base UI Dialog 2026-07-06 20:49:25 +02:00

@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.css fournit des directives (@custom-variant, @theme, @utility) qui se résolvent dans ton graphe Tailwind.
  • @source doit cibler uniquement @azsiaz/ui/dist (ne jamais scanner tout node_modules).
  • tailwindcss doit ê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éfaut azsiaz-sidebar-collapsed, aligné sur SidebarProvider) — évite le flash sidebar dépliée→repliée en posant data-sidebar-collapsed sur <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 par Set<string>, virtualisation opt-in (virtual + maxHeight, TanStack Virtual), stickyHeader, onRowClick, escape hatch tableRef vers l'instance TanStack Table. La pagination reste externe (composant Pagination).

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) :

  1. Bump la version dans package.json (patch/minor/major) sur une branche, ouvre une PR — la CI joue le workflow check (lint → typecheck → test → build → check:packaging).
  2. Une fois mergée sur main, le step publish compare package.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), il pnpm publish --no-git-checks puis pousse le tag vX.Y.Z ; sinon il ne fait rien.

Secrets Woodpecker requis sur le dépôt :

  • npm_token — jeton Forgejo scope write:package (écrit .npmrc avec //forge.netserv.fr/api/packages/AzSiAz/npm/:_authToken=…).
  • forge_git_token — jeton scope write: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é.