cdc8829ce7
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>
298 lines
10 KiB
Markdown
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.
|