Files
ewa-ui/docs/ARCHITECTURE.md
T
Kaloyan Danchev cdc8829ce7 Initial commit: @ampeco/ewa-ui v1.0.0
Shared React component library with Tailwind plugin for AMPECO EWAs.
8 components: Button, Card, Input, StatusBadge, Loader, EmptyState, Toast, BottomSheet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 19:34:11 +02:00

298 lines
10 KiB
Markdown

# Architecture
Technical internals of the `@ampeco/ewa-ui` package.
## Design Philosophy
1. **CSS-variable-driven theming.** Components never hardcode colors. Every visual property maps to a CSS custom property (`--btn-primary-bg`, `--surface-bg`, etc.), enabling white-labeling by swapping variable values at the `:root` level.
2. **44px touch targets.** All interactive elements (buttons, inputs, toast dismiss) use `h-11` (44px) minimum height, meeting WCAG 2.5.8 and Apple HIG touch-target guidelines.
3. **Accessibility first.** Interactive `Card` gets `role="button"`, `tabIndex`, and `Enter`/`Space` key handlers. `Loader` uses `role="status"` + `aria-label`. `Toast` uses `role="alert"`. `BottomSheet` uses `role="dialog"` + `aria-modal`. Focus rings use `focus-visible` to avoid visual clutter on mouse clicks.
4. **Tailwind-native styling.** Components compose Tailwind utility classes directly -- no CSS files, no CSS-in-JS runtime. The Tailwind plugin registers the design tokens so standard utilities like `bg-surface-elevation-1` or `text-body-medium` resolve to the correct CSS variable values.
## Package Entry Points
Defined in `package.json` `exports`:
| Import path | Resolves to | Purpose |
|---|---|---|
| `@ampeco/ewa-ui` | `dist/index.js` / `dist/index.cjs` | React components + type exports |
| `@ampeco/ewa-ui/plugin` | `dist/plugin.js` / `dist/plugin.cjs` | Tailwind CSS plugin (no React dependency) |
Both entry points ship ESM and CJS formats via tsup, with `.d.ts` and `.d.cts` type declarations.
Build config: `tsup.config.ts` -- two entry points, tree-shakeable, React externalized.
## Tailwind Plugin Internals
Source: `src/plugin.ts`
The plugin is built with `tailwindcss/plugin` and does three things:
### 1. Base Styles
```js
addBase({
'*, *::before, *::after': { 'box-sizing': 'border-box' },
'body': {
'font-family': "'Inter', system-ui, -apple-system, sans-serif",
'-webkit-font-smoothing': 'antialiased',
'-moz-osx-font-smoothing': 'grayscale',
},
});
```
Sets universal box-sizing and the Inter font stack with subpixel antialiasing.
### 2. Status Background Utilities
Generates `.bg-status-{name}-subtle` utilities for each status using `color-mix()`:
```js
const statusColors = ['accepted', 'reserved', 'completed', 'cancelled', 'no-show', 'failed'];
// Produces:
// .bg-status-accepted-subtle { background-color: color-mix(in srgb, var(--status-accepted) 15%, transparent); }
// .bg-status-reserved-subtle { background-color: color-mix(in srgb, var(--status-reserved) 15%, transparent); }
// ...
```
This is implemented via `addUtilities()` rather than theme extension because it requires `color-mix()` with a CSS variable -- something Tailwind's color system cannot express natively.
### 3. Theme Extension
Extends Tailwind's default theme with tokens that resolve to CSS custom properties:
#### Colors
| Tailwind class prefix | CSS variable | Usage |
|---|---|---|
| `bg-btn-primary-bg` | `--btn-primary-bg` | Primary button background |
| `text-btn-primary-text` | `--btn-primary-text` | Primary button text |
| `bg-btn-secondary-bg` | `--btn-secondary-bg` | Secondary button hover fill |
| `border-btn-secondary-border` | `--btn-secondary-border` | Secondary button border |
| `text-btn-secondary-text` | `--btn-secondary-text` | Secondary button text |
| `bg-surface-bg` | `--surface-bg` | Page/app background |
| `bg-surface-elevation-1` | `--surface-elevation-1` | Card surfaces |
| `bg-surface-elevation-2` | `--surface-elevation-2` | Dividers, loader track |
| `text-text-base-color` | `--text-base` | Primary text |
| `text-text-support` | `--text-support` | Secondary/muted text |
| `text-text-placeholder` | `--text-placeholder` | Input placeholders |
| `bg-feedback-success` | `--feedback-success` | Success color (solid) |
| `bg-feedback-error` | `--feedback-error` | Error color (solid) |
| `bg-feedback-warning` | `--feedback-warning` | Warning color (solid) |
| `bg-feedback-info` | `--feedback-info` | Info color (solid) |
| `bg-feedback-success-subtle` | `--feedback-success-subtle` | Success background (light) |
| `bg-feedback-error-subtle` | `--feedback-error-subtle` | Error background (light) |
| `bg-feedback-warning-subtle` | `--feedback-warning-subtle` | Warning background (light) |
| `bg-feedback-info-subtle` | `--feedback-info-subtle` | Info background (light) |
| `bg-input-fill` | `--input-fill` | Input background |
| `border-input-border` | `--input-border` | Input border (default) |
| `border-input-border-active` | `--input-border-active` | Input border (focus) |
| `text-input-text` | `--input-text` | Input text |
| `border-separator` | `--separator` | Divider lines |
| `text-status-accepted` | `--status-accepted` | Booking status: accepted |
| `text-status-reserved` | `--status-reserved` | Booking status: reserved |
| `text-status-completed` | `--status-completed` | Booking status: completed |
| `text-status-cancelled` | `--status-cancelled` | Booking status: cancelled |
| `text-status-no-show` | `--status-no-show` | Booking status: no-show |
| `text-status-failed` | `--status-failed` | Booking status: failed |
#### Spacing Scale
| Token | CSS variable | Suggested value |
|---|---|---|
| `xxxs` | `--space-xxxs` | 2px |
| `xxs` | `--space-xxs` | 4px |
| `xs` | `--space-xs` | 8px |
| `s` | `--space-s` | 12px |
| `m` | `--space-m` | 16px |
| `l` | `--space-l` | 20px |
| `xl` | `--space-xl` | 24px |
| `xxl` | `--space-xxl` | 32px |
| `xxxl` | `--space-xxxl` | 48px |
Used as: `p-l`, `gap-xs`, `mt-m`, `mb-xxl`, etc.
#### Border Radius
| Token | CSS variable |
|---|---|
| `s` | `--radius-s` |
| `m` | `--radius-m` |
| `l` | `--radius-l` |
| `xl` | `--radius-xl` |
Used as: `rounded-s`, `rounded-m`, `rounded-l`, `rounded-xl`.
#### Typography Scale
| Utility | Size | Line height | Weight |
|---|---|---|---|
| `text-h1` | 22px | 26px | 600 |
| `text-h2` | 18px | 22px | 600 |
| `text-h3` | 16px | 20px | 500 |
| `text-h4` | 14px | 17px | 500 |
| `text-body-large` | 16px | 20px | 400 |
| `text-body-medium` | 14px | 17px | 400 |
| `text-body-large-heavy` | 16px | 20px | 600 |
| `text-body-medium-heavy` | 14px | 17px | 600 |
| `text-buttons` | 14px | 20px | 600 |
| `text-buttons-small` | 12px | 17px | 700 |
| `text-label` | 12px | 15px | 400 |
| `text-caption` | 12px | 15px | 500 |
Each utility sets `font-size`, `line-height`, and `font-weight` simultaneously via Tailwind's `fontSize` tuple syntax.
#### Animations
| Utility | Keyframes | Duration | Timing |
|---|---|---|---|
| `animate-spin` | `spin` | 1s | linear, infinite |
| `animate-slide-in` | `slideIn` (translateY -100% to 0, fade) | 0.2s | ease-out |
| `animate-slide-up` | `slideUp` (translateY 100% to 0) | 0.3s | ease-out |
| `animate-fade-in` | `fadeIn` (opacity 0 to 1) | 0.2s | ease-out |
## How Status Colors Work
Status colors use a two-layer system:
1. **Solid color** -- `var(--status-accepted)` etc. -- used for text via `text-[var(--status-accepted)]` in `StatusBadge`.
2. **Subtle background** -- generated by the Tailwind plugin as a custom utility class:
```css
.bg-status-accepted-subtle {
background-color: color-mix(in srgb, var(--status-accepted) 15%, transparent);
}
```
`color-mix(in srgb, <color> 15%, transparent)` blends the status color at 15% opacity with transparency. This produces a tinted background that automatically adapts to any status color value without needing a separate `--status-accepted-subtle` variable.
The `StatusBadge` component (`src/components/StatusBadge.tsx`) applies both layers:
```tsx
className={[
`bg-status-${mappedStatus}-subtle`, // plugin-generated utility
`text-[var(--status-${mappedStatus})]`, // arbitrary value for text color
].join(' ')}
```
The `statusMap` normalizes API values (e.g. `no_show` with underscore) to CSS-friendly names (`no-show` with hyphen).
## Component Hierarchy
```
EmptyState
└── Button (renders the optional action CTA)
Toast
└── (self-contained, no child components)
BottomSheet
└── (renders children; scroll-locks body)
Card
└── (renders children; conditionally interactive)
Button
└── (self-contained with inline SVG spinner)
Input
└── (self-contained; forwardRef)
StatusBadge
└── (self-contained)
Loader
└── (self-contained)
```
`EmptyState` is the only component with an internal component dependency (it imports `Button`). All other components are leaf nodes.
## CSS Variable Contract
The consuming application must define these CSS custom properties (typically on `:root` or a theme wrapper element). The plugin does not provide default values -- all colors, spacing, and radii are expected to be injected by the host.
Minimal example:
```css
:root {
/* Buttons */
--btn-primary-bg: #2563eb;
--btn-primary-text: #ffffff;
--btn-secondary-bg: #f1f5f9;
--btn-secondary-border: #cbd5e1;
--btn-secondary-text: #1e293b;
/* Surfaces */
--surface-bg: #ffffff;
--surface-elevation-1: #ffffff;
--surface-elevation-2: #e2e8f0;
/* Text */
--text-base: #0f172a;
--text-support: #64748b;
--text-placeholder: #94a3b8;
/* Feedback */
--feedback-success: #16a34a;
--feedback-error: #dc2626;
--feedback-warning: #d97706;
--feedback-info: #2563eb;
--feedback-success-subtle: #f0fdf4;
--feedback-error-subtle: #fef2f2;
--feedback-warning-subtle: #fffbeb;
--feedback-info-subtle: #eff6ff;
/* Inputs */
--input-fill: #f8fafc;
--input-border: #e2e8f0;
--input-border-active: #2563eb;
--input-text: #0f172a;
/* Separator */
--separator: #e2e8f0;
/* Status */
--status-accepted: #2563eb;
--status-reserved: #7c3aed;
--status-completed: #16a34a;
--status-cancelled: #64748b;
--status-no-show: #d97706;
--status-failed: #dc2626;
/* Spacing */
--space-xxxs: 2px;
--space-xxs: 4px;
--space-xs: 8px;
--space-s: 12px;
--space-m: 16px;
--space-l: 20px;
--space-xl: 24px;
--space-xxl: 32px;
--space-xxxl: 48px;
/* Radii */
--radius-s: 6px;
--radius-m: 10px;
--radius-l: 16px;
--radius-xl: 24px;
}
```
## Safe Area Handling
`BottomSheet` accounts for notched/home-bar devices via:
```css
padding-bottom: calc(var(--space-l) + env(safe-area-inset-bottom));
```
Consuming apps should include `<meta name="viewport" content="..., viewport-fit=cover">` for this to take effect.
## Scroll Locking
`BottomSheet` sets `document.body.style.overflow = 'hidden'` while open and restores the original value on close (via `useEffect` cleanup). This prevents background scroll while the modal is visible.