Skip to content

Latest commit

 

History

History
1021 lines (806 loc) · 25.9 KB

File metadata and controls

1021 lines (806 loc) · 25.9 KB

UI Design Guidelines

Design System Documentation for KanaDojo
A comprehensive guide to UI development, theming, and best practices for our TypeScript Next.js application using Tailwind CSS.


📋 Table of Contents


🛠️ Project Stack

KanaDojo is built with modern web technologies optimized for performance and developer experience:

  • TypeScript - Type-safe development
  • Next.js 15 - App Router with React 19
  • Tailwind CSS - Utility-first styling
  • shadcn/ui - High-quality, accessible component library (slow adoption in progress)
  • Framer Motion - Smooth animations via motion package
  • Zustand - Lightweight state management
  • Lucide React - Icon system

Key Dependencies

  • clsx + tailwind-merge - Conditional class management via cn() utility
  • class-variance-authority - Component variant management
  • @radix-ui - Accessible component primitives (via shadcn/ui)

🎨 Current Approach

How We Use Tailwind CSS

KanaDojo leverages Tailwind CSS as the primary styling solution with a heavy emphasis on CSS custom properties (CSS variables) for theming. This approach provides:

  1. Dynamic theming - Runtime theme switching without rebuilding styles
  2. Consistency - Centralized color and spacing values
  3. Flexibility - Easy theme creation and customization

Current Conventions

1. CSS Variables Over Hardcoded Colors

✅ DO:

<div className="bg-[var(--card-color)] text-[var(--main-color)]">
  Content
</div>

❌ DON'T:

<div className="bg-gray-100 text-black">
  Content
</div>

2. Utility Classes with cn() Helper

We use the cn() utility from lib/utils.ts to merge Tailwind classes intelligently:

import { cn } from '@/lib/utils';

<button className={cn(
  "px-4 py-2 rounded-lg",
  isActive && "bg-[var(--main-color)]",
  disabled && "opacity-50 cursor-not-allowed"
)}>
  Click me
</button>

3. Reusable Style Constants

Common patterns are extracted to static/styles.ts:

// static/styles.ts
import clsx from 'clsx';

export const cardBorderStyles = clsx(
  'rounded-xl bg-[var(--card-color)]'
);

export const buttonBorderStyles = clsx(
  'rounded-xl bg-[var(--card-color)] hover:cursor-pointer',
  'duration-250 transition-all ease-in-out',
  'hover:bg-[var(--border-color)]'
);

Usage:

import { buttonBorderStyles } from '@/static/styles';

<button className={buttonBorderStyles}>
  Click me
</button>

4. Responsive Design

Use Tailwind's responsive prefixes consistently:

<div className="flex flex-col md:flex-row gap-4 md:gap-6 lg:gap-8">
  {/* Content adapts to screen size */}
</div>

Breakpoints:

  • sm: 640px
  • md: 768px
  • lg: 1024px
  • xl: 1280px
  • 2xl: 1536px
  • xs: 30rem (custom)
  • 3xl: 110rem (custom)

🎭 Theming System

Core Theme Variables

KanaDojo uses a 5-variable color system defined in app/globals.css:

:root {
  /* Layout Colors */
  --background-color: hsla(210, 17%, 100%, 1);  /* Page background */
  --card-color: hsla(210, 17%, 91%, 1);         /* Card/elevated surfaces */
  --border-color: hsla(210, 17%, 76%, 1);       /* Borders and dividers */
  
  /* Content Colors */
  --main-color: hsl(0, 0%, 0%);                 /* Primary text and actions */
  --secondary-color: hsl(0, 0%, 35%);           /* Secondary text and icons */
}

Theme Structure

Themes are defined in static/themes.ts with TypeScript interfaces:

interface Theme {
  id: string;
  backgroundColor: string;  // Page background
  cardColor: string;        // Cards, modals, elevated surfaces
  borderColor: string;      // Borders, dividers, hover states
  mainColor: string;        // Primary text, icons, CTAs
  secondaryColor: string;   // Secondary text, subtle elements
}

How Themes Work

  1. Theme Definition - Themes are organized into groups (Base, Light, Dark) in static/themes.ts
  2. Theme Storage - Selected theme ID is persisted via Zustand in store/useThemeStore.ts
  3. Theme Application - The applyTheme() function dynamically updates CSS variables:
export function applyTheme(themeId: string) {
  const theme = themeMap.get(themeId);
  if (!theme) return;

  const root = document.documentElement;
  root.style.setProperty('--background-color', theme.backgroundColor);
  root.style.setProperty('--card-color', theme.cardColor);
  root.style.setProperty('--border-color', theme.borderColor);
  root.style.setProperty('--main-color', theme.mainColor);
  root.style.setProperty('--secondary-color', theme.secondaryColor);
  root.setAttribute('data-theme', theme.id);
}

Creating New Themes

To add a new theme:

  1. Define the theme in static/themes.ts:
{
  id: 'my-custom-theme',
  backgroundColor: 'hsla(220, 20%, 12%, 1)',
  cardColor: 'hsla(220, 20%, 18%, 1)',
  borderColor: 'hsla(220, 20%, 30%, 1)',
  mainColor: 'hsla(280, 80%, 65%, 1)',
  secondaryColor: 'hsla(180, 70%, 55%, 1)'
}
  1. Test contrast ratios (see Accessibility section)
  2. Add to appropriate theme group (Base, Light, or Dark)

Theme Color Guidelines

Color Format

  • Use HSLA for flexibility: hsla(hue, saturation%, lightness%, alpha)
  • HSL makes it easier to create harmonious color schemes

Naming Convention

  • Theme IDs should be descriptive: 'midnight-purple', 'sunset-orange'
  • Use kebab-case for consistency

Color Relationships

  • backgroundColor → Lightest/darkest (depending on light/dark theme)
  • cardColor → Slightly elevated from background
  • borderColor → More prominent than card, used for hover states
  • mainColor → High contrast with background
  • secondaryColor → Medium contrast, complementary to main

Example Hierarchy (Dark Theme):

backgroundColor: hsl(220, 15%, 10%)  ← Darkest
cardColor:       hsl(220, 15%, 15%)  ← Slightly lighter
borderColor:     hsl(220, 15%, 25%)  ← More prominent
mainColor:       hsl(280, 80%, 70%)  ← Vibrant, high contrast
secondaryColor:  hsl(180, 60%, 60%)  ← Complementary accent

♿ Accessibility Guidelines

Color Contrast

All themes MUST meet WCAG 2.1 Level AA standards:

  • Normal text (< 18pt): Contrast ratio ≥ 4.5:1
  • Large text (≥ 18pt or ≥ 14pt bold): Contrast ratio ≥ 3:1
  • UI components (buttons, borders): Contrast ratio ≥ 3:1

Tools for Testing:

Example Validation:

// Test main text readability
mainColor (hsl(280, 80%, 70%)) on backgroundColor (hsl(220, 15%, 10%))
 Contrast ratio: 8.2:1  Passes AA and AAA

// Test secondary text readability
secondaryColor (hsl(180, 60%, 60%)) on backgroundColor (hsl(220, 15%, 10%))
 Contrast ratio: 5.1:1  Passes AA

Focus States

All interactive elements MUST have visible focus indicators:

// Example from components/ui/button.tsx
const buttonVariants = cva(
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--main-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background-color)]",
  // ... other styles
);

Focus Ring Guidelines:

  • Use focus-visible:ring-2 for keyboard navigation
  • Ring color: ring-[var(--main-color)]
  • Ring offset: ring-offset-2 for separation
  • Ring offset color: ring-offset-[var(--background-color)]

Semantic HTML

Use proper HTML elements for their intended purpose:

✅ DO:

<button onClick={handleClick}>Submit</button>
<a href="/about">Learn More</a>

❌ DON'T:

<div onClick={handleClick}>Submit</div>
<span onClick={navigate}>Learn More</span>

ARIA Labels

Provide context for screen readers when visual cues aren't sufficient:

<button aria-label="Close modal">
  <X size={20} />
</button>

<input
  type="checkbox"
  aria-checked={isSelected}
  aria-label="Select all Hiragana characters"
/>

Keyboard Navigation

  • Ensure all interactive elements are keyboard accessible
  • Support Tab, Enter, Space, Escape, and arrow keys where appropriate
  • Maintain logical tab order

🧩 Component Patterns

Component Structure

Follow these patterns for consistency:

'use client'; // Add for client components (state, effects, etc.)

import { useState } from 'react';
import { cn } from '@/lib/utils';
import useThemeStore from '@/store/useThemeStore';

interface MyComponentProps {
  title: string;
  isActive?: boolean;
  onClick?: () => void;
}

const MyComponent = ({ title, isActive = false, onClick }: MyComponentProps) => {
  const [state, setState] = useState('');
  const theme = useThemeStore(state => state.theme);

  return (
    <div className={cn(
      "rounded-xl p-4",
      "bg-[var(--card-color)] text-[var(--main-color)]",
      isActive && "border-2 border-[var(--main-color)]"
    )}>
      <h3 className="text-xl font-semibold">{title}</h3>
      {/* ... */}
    </div>
  );
};

export default MyComponent;

Styling Patterns

1. Container Layouts

<div className="container mx-auto max-w-7xl px-4 md:px-6 lg:px-8">
  {/* Responsive container with proper padding */}
</div>

2. Card Components

<div className="rounded-xl bg-[var(--card-color)] p-6 shadow-sm">
  {/* Card content */}
</div>

3. Buttons (Custom Styles)

<button className={cn(
  "px-6 py-3 rounded-lg",
  "bg-[var(--main-color)] text-[var(--background-color)]",
  "hover:brightness-110 active:brightness-95",
  "transition-all duration-200",
  "focus-visible:ring-2 focus-visible:ring-[var(--main-color)] focus-visible:ring-offset-2"
)}>
  Action
</button>

4. Text Hierarchy

<h1 className="text-4xl md:text-5xl font-bold text-[var(--main-color)]">
  Main Heading
</h1>
<h2 className="text-2xl md:text-3xl font-semibold text-[var(--main-color)]">
  Subheading
</h2>
<p className="text-base text-[var(--secondary-color)]">
  Body text with secondary color
</p>

5. Hover States

<div className={cn(
  "p-4 rounded-lg",
  "bg-[var(--card-color)]",
  "hover:bg-[var(--border-color)] hover:cursor-pointer",
  "transition-all duration-200"
)}>
  {/* Hoverable content */}
</div>

Animation Patterns

Use Framer Motion (motion package) for complex animations:

import { motion } from 'motion/react';

<motion.div
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ duration: 0.3 }}
>
  {/* Animated content */}
</motion.div>

Use Tailwind transitions for simple interactions:

<button className="transition-all duration-200 hover:scale-105 active:scale-95">
  Click me
</button>

🚀 shadcn/ui Adoption Strategy

KanaDojo is gradually adopting shadcn/ui for consistent, accessible components.

Current shadcn/ui Components

  • Button (components/ui/button.tsx)
  • Select (components/ui/select.tsx)

shadcn/ui Integration Guidelines

1. Theme Variable Compatibility

shadcn/ui components are customized to use our CSS variable system:

// components/ui/button.tsx
const buttonVariants = cva(
  "bg-[var(--main-color)] text-[var(--background-color)]", // Uses our theme
  // ...
);

2. Installing New Components

Use the shadcn CLI to add components:

npx shadcn@latest add [component-name]

After installation:

  1. Review the generated component
  2. Replace hardcoded colors with CSS variables:
    • bg-primarybg-[var(--main-color)]
    • text-primary-foregroundtext-[var(--background-color)]
    • borderborder-[var(--border-color)]
  3. Test with multiple themes to ensure compatibility

3. Component Customization

Example: Customizing Button variants

// components/ui/button.tsx
const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-transparent text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--main-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background-color)] disabled:pointer-events-none disabled:opacity-60",
  {
    variants: {
      variant: {
        default:
          "bg-[var(--main-color)] text-[var(--background-color)] shadow-[0_10px_30px_-12px_rgba(0,0,0,0.45)] hover:brightness-110",
        outline:
          "border border-[var(--border-color)] bg-transparent text-[var(--main-color)] hover:bg-[var(--card-color)]",
        ghost:
          "bg-transparent text-[var(--main-color)] hover:bg-[var(--card-color)]",
        // Add custom variants as needed
      },
      size: {
        default: "h-10 px-5",
        sm: "h-8 rounded-md px-3 text-xs",
        lg: "h-12 rounded-xl px-8 text-base",
        icon: "h-10 w-10",
      },
    },
  }
);

4. Migration Strategy

Phase 1: High-Priority Components (Current)

  • ✅ Button
  • ✅ Select
  • 🔄 Input (next)
  • 🔄 Checkbox (next)

Phase 2: Form Components

  • 🔜 Form wrapper
  • 🔜 Label
  • 🔜 Textarea
  • 🔜 Switch

Phase 3: Overlay Components

  • 🔜 Dialog/Modal
  • 🔜 Dropdown Menu
  • 🔜 Tooltip

Migration Guidelines:

  1. Don't break existing UI - Migrate incrementally, one component at a time
  2. Test thoroughly - Verify all game modes and features still work
  3. Maintain theme compatibility - Always use CSS variables
  4. Update documentation - Document new shadcn components in this file

Future-Proofing for shadcn

DO:

  • ✅ Use CSS variables consistently
  • ✅ Follow Radix UI patterns (shadcn is built on Radix)
  • ✅ Use the cn() utility for class management
  • ✅ Maintain semantic HTML structure

DON'T:

  • ❌ Hardcode colors or theme values
  • ❌ Create custom components that duplicate shadcn functionality
  • ❌ Override Radix UI accessibility features

✅ Best Practices

Do's and Don'ts

Styling

✅ DO:

// Use CSS variables for dynamic theming
<div className="bg-[var(--card-color)] text-[var(--main-color)]" />

// Use cn() for conditional classes
<button className={cn(
  "px-4 py-2",
  isActive && "bg-[var(--main-color)]"
)} />

// Extract repeated patterns to static/styles.ts
import { buttonBorderStyles } from '@/static/styles';

// Use responsive prefixes consistently
<div className="flex flex-col md:flex-row lg:gap-8" />

❌ DON'T:

// Don't hardcode colors
<div className="bg-gray-100 text-black" />

// Don't use string concatenation for classes
<button className={"px-4 py-2 " + (isActive ? "bg-blue-500" : "")} />

// Don't repeat complex class strings
<button className="rounded-xl bg-[var(--card-color)] hover:bg-[var(--border-color)] transition-all duration-200" />
<div className="rounded-xl bg-[var(--card-color)] hover:bg-[var(--border-color)] transition-all duration-200" />

// Don't use arbitrary breakpoint values
<div className="flex flex-col min-[850px]:flex-row" /> // Use md: instead

Typography

✅ DO:

// Use semantic heading levels
<h1 className="text-4xl font-bold">Main Title</h1>
<h2 className="text-2xl font-semibold">Section Title</h2>

// Use secondary color for less prominent text
<p className="text-[var(--secondary-color)]">Helper text</p>

// Use responsive text sizes
<h1 className="text-3xl md:text-4xl lg:text-5xl">Responsive Title</h1>

❌ DON'T:

// Don't skip heading levels
<h1>Title</h1>
<h3>Skipped h2</h3>

// Don't use hardcoded gray values
<p className="text-gray-500">Text</p>

// Don't use fixed sizes that don't scale
<p className="text-[14px]">Fixed size text</p>

Spacing

✅ DO:

// Use Tailwind spacing scale
<div className="p-4 md:p-6 lg:p-8" />
<div className="space-y-4" /> // Consistent vertical spacing
<div className="gap-4 md:gap-6" /> // Responsive gaps in flex/grid

// Use consistent spacing within components
const spacing = "p-6 space-y-4";

❌ DON'T:

// Don't use arbitrary values unnecessarily
<div className="p-[17px] space-y-[23px]" />

// Don't mix spacing units
<div className="p-4 mb-[2rem]" /> // Inconsistent

Interactions

✅ DO:

// Add hover states for interactive elements
<button className="hover:brightness-110 transition-duration-200" />

// Use focus-visible for keyboard navigation
<button className="focus-visible:ring-2 focus-visible:ring-[var(--main-color)]" />

// Provide feedback on active/pressed states
<button className="active:brightness-95" />

// Use appropriate cursor styles
<div className="cursor-pointer" onClick={handler} />

❌ DON'T:

// Don't forget hover states
<button onClick={handler}>No hover feedback</button>

// Don't use focus instead of focus-visible (affects mouse users)
<button className="focus:ring-2" />

// Don't make non-interactive elements look clickable
<div className="cursor-pointer">Not actually clickable</div>

Component Organization

✅ DO:

// Group related components
components/
  Settings/
    Themes.tsx
    Preferences.tsx
  Dojo/
    Kana/
    Kanji/
    Vocab/

// Use TypeScript interfaces from lib/interfaces.ts
import { KanaCharacter } from '@/lib/interfaces';

// Use custom hooks from lib/hooks/
import { useClick } from '@/lib/hooks/useAudio';

❌ DON'T:

// Don't create deeply nested component structures
components/
  Settings/
    ThemeSection/
      ThemesList/
        ThemeItem/
          index.tsx // Too deep

// Don't duplicate interface definitions
interface KanaCharacter { } // Already in lib/interfaces.ts

Performance Considerations

  1. Use 'use client' directive wisely - Only add to components that need client-side features (state, effects, event handlers)
  2. Optimize images - Use Next.js Image component for automatic optimization
  3. Lazy load heavy components - Use dynamic imports for large components
  4. Memoize expensive calculations - Use useMemo and useCallback appropriately
  5. Minimize re-renders - Use Zustand selectors to subscribe to specific state slices

💡 Code Examples

Example 1: Themed Card Component

'use client';

import { cn } from '@/lib/utils';
import { cardBorderStyles } from '@/static/styles';

interface CardProps {
  title: string;
  description?: string;
  children?: React.ReactNode;
  className?: string;
}

const Card = ({ title, description, children, className }: CardProps) => {
  return (
    <div className={cn(
      cardBorderStyles,
      "p-6 space-y-4",
      className
    )}>
      <div className="space-y-2">
        <h3 className="text-xl font-semibold text-[var(--main-color)]">
          {title}
        </h3>
        {description && (
          <p className="text-sm text-[var(--secondary-color)]">
            {description}
          </p>
        )}
      </div>
      {children}
    </div>
  );
};

export default Card;

Example 2: Interactive Selection Button

'use client';

import { cn } from '@/lib/utils';
import { buttonBorderStyles } from '@/static/styles';
import { useClick } from '@/lib/hooks/useAudio';
import { Check } from 'lucide-react';

interface SelectionButtonProps {
  label: string;
  isSelected: boolean;
  onToggle: () => void;
}

const SelectionButton = ({ label, isSelected, onToggle }: SelectionButtonProps) => {
  const { playClick } = useClick();

  const handleClick = () => {
    playClick();
    onToggle();
  };

  return (
    <button
      onClick={handleClick}
      className={cn(
        buttonBorderStyles,
        "p-4 flex items-center justify-between gap-3",
        "transition-all duration-200",
        isSelected && "border-2 border-[var(--main-color)]"
      )}
      aria-pressed={isSelected}
    >
      <span className="text-[var(--main-color)] font-medium">
        {label}
      </span>
      {isSelected && (
        <Check className="text-[var(--secondary-color)]" size={20} />
      )}
    </button>
  );
};

export default SelectionButton;

Example 3: Responsive Grid Layout

<div className="container mx-auto px-4 md:px-6 lg:px-8 py-8">
  <h1 className="text-3xl md:text-4xl font-bold text-[var(--main-color)] mb-8">
    Character Selection
  </h1>
  
  <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6">
    {characters.map((char) => (
      <CharacterCard key={char.id} character={char} />
    ))}
  </div>
</div>

Example 4: Themed Modal with Overlay

'use client';

import { motion, AnimatePresence } from 'motion/react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

const Modal = ({ isOpen, onClose, title, children }: ModalProps) => {
  return (
    <AnimatePresence>
      {isOpen && (
        <>
          {/* Overlay */}
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
            className="fixed inset-0 bg-black/50 z-40"
          />
          
          {/* Modal */}
          <motion.div
            initial={{ opacity: 0, scale: 0.95, y: 20 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.95, y: 20 }}
            className={cn(
              "fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50",
              "w-full max-w-md max-h-[85vh] overflow-y-auto",
              "bg-[var(--background-color)] rounded-2xl shadow-2xl",
              "p-6"
            )}
          >
            {/* Header */}
            <div className="flex items-center justify-between mb-6">
              <h2 className="text-2xl font-bold text-[var(--main-color)]">
                {title}
              </h2>
              <button
                onClick={onClose}
                className={cn(
                  "rounded-lg p-2",
                  "hover:bg-[var(--card-color)]",
                  "transition-colors duration-200",
                  "focus-visible:ring-2 focus-visible:ring-[var(--main-color)]"
                )}
                aria-label="Close modal"
              >
                <X className="text-[var(--secondary-color)]" size={24} />
              </button>
            </div>
            
            {/* Content */}
            <div className="space-y-4">
              {children}
            </div>
          </motion.div>
        </>
      )}
    </AnimatePresence>
  );
};

export default Modal;

Example 5: Custom Checkbox Styling

The project uses custom checkbox styling in app/globals.css:

/* Custom styled checkbox using CSS variables */
input[type='checkbox'] {
  appearance: none;
  -webkit-appearance: none;
  background-color: var(--card-color);
  border: 2px solid var(--border-color);
  width: 1.1em;
  height: 1.1em;
  border-radius: 0.25em;
  display: inline-block;
  position: relative;
  vertical-align: middle;
  cursor: pointer;
  transition: border-color 0.2s, background-color 0.2s;
}

input[type='checkbox']:checked {
  background-color: var(--main-color);
  border-color: var(--main-color);
}

input[type='checkbox']:checked::after {
  content: '';
  display: block;
  position: absolute;
  left: 0.28em;
  top: 0.05em;
  width: 0.3em;
  height: 0.6em;
  border: solid var(--background-color);
  border-width: 0 0.18em 0.18em 0;
  transform: rotate(45deg);
}

Usage in JSX:

<label className="flex items-center gap-2 cursor-pointer">
  <input
    type="checkbox"
    checked={isSelected}
    onChange={(e) => setIsSelected(e.target.checked)}
    className="focus-visible:ring-2 focus-visible:ring-[var(--main-color)]"
  />
  <span className="text-[var(--main-color)]">Option label</span>
</label>

Example 6: Using shadcn/ui Button

import { Button } from '@/components/ui/button';

// Default variant - primary action
<Button onClick={handleSubmit}>
  Submit
</Button>

// Outline variant - secondary action
<Button variant="outline" onClick={handleCancel}>
  Cancel
</Button>

// Ghost variant - tertiary action
<Button variant="ghost" onClick={handleReset}>
  Reset
</Button>

// Different sizes
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon">
  <Settings size={20} />
</Button>

// With custom classes
<Button className="w-full md:w-auto">
  Responsive Width
</Button>

📚 Additional Resources

Internal Documentation

  • CLAUDE.md - Project overview and architecture
  • CONTRIBUTING.md - Contribution guidelines and code style
  • static/themes.ts - Theme definitions and management
  • lib/interfaces.ts - TypeScript interfaces
  • static/styles.ts - Reusable style constants

External Resources

Design Tools


🔄 Document Maintenance

This document should be updated whenever:

  • New theming patterns are established
  • shadcn/ui components are added or customized
  • CSS variable naming conventions change
  • New accessibility requirements are identified
  • Major design system changes are implemented

Last Updated: [Current Date]
Maintained By: KanaDojo Team


Questions or Suggestions?
Please open an issue or discussion on GitHub to improve this documentation.