@medalsocial/meda 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { ChevronDown, Monitor, Moon, PanelRightClose, PanelRightOpen, Sun } from 'lucide-react';
3
+ import { ChevronDown, Monitor, Moon, PanelRight, Sun } from 'lucide-react';
4
4
  import { createElement, Fragment, isValidElement } from 'react';
5
5
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '../components/ui/dropdown-menu.js';
6
6
  import { cn } from '../lib/utils.js';
@@ -57,7 +57,7 @@ export function WorkspaceSwitcher({ menuItems, menuFooter, workspaceMenuFooter,
57
57
  const { workspace, workspaces } = useMedaShell();
58
58
  const resolvedFooter = menuFooter ?? workspaceMenuFooter;
59
59
  const useConfiguredItems = Array.isArray(menuItems);
60
- return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", className: "flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-accent" }), children: [workspace.icon != null && (_jsx("span", { className: "shrink-0", "aria-hidden": "true", children: workspace.icon })), _jsx("span", { children: workspace.name }), _jsx(ChevronDown, { size: 14, "aria-hidden": "true" })] }), _jsxs(DropdownMenuContent, { className: "min-w-[200px]", children: [workspaces.length > 0 && (_jsxs(_Fragment, { children: [workspaces.map((ws) => (_jsxs(DropdownMenuItem, { children: [ws.icon != null && _jsx("span", { "aria-hidden": "true", children: ws.icon }), ws.name] }, ws.id))), _jsx(DropdownMenuSeparator, {})] })), useConfiguredItems ? (menuItems.map((item) => (_jsxs(Fragment, { children: [renderConfiguredItem(item), item.separatorAfter && _jsx(DropdownMenuSeparator, {})] }, item.id)))) : (_jsxs(_Fragment, { children: [_jsx(DropdownMenuItem, { children: "Manage workspaces" }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Settings" }), _jsx(DropdownMenuItem, { children: "Profile" })] })), _jsx(DropdownMenuSeparator, {}), _jsx(ThemeToggleMenuItem, {}), !useConfiguredItems && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Sign out" })] })), resolvedFooter] })] }));
60
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", className: "flex min-w-0 items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-semibold hover:bg-accent" }), children: [workspace.icon != null && (_jsx("span", { className: "inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground ring-1 ring-border/70", "aria-hidden": "true", children: workspace.icon })), _jsx("span", { className: "max-w-[13rem] truncate", children: workspace.name }), _jsx(ChevronDown, { size: 16, "aria-hidden": "true" })] }), _jsxs(DropdownMenuContent, { className: "min-w-[240px]", children: [!useConfiguredItems && workspaces.length > 0 && (_jsxs(_Fragment, { children: [workspaces.map((ws) => (_jsxs(DropdownMenuItem, { children: [ws.icon != null && (_jsx("span", { className: "inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted text-foreground ring-1 ring-border/70", "aria-hidden": "true", children: ws.icon })), ws.name] }, ws.id))), _jsx(DropdownMenuSeparator, {})] })), useConfiguredItems ? (menuItems.map((item) => (_jsxs(Fragment, { children: [renderConfiguredItem(item), item.separatorAfter && _jsx(DropdownMenuSeparator, {})] }, item.id)))) : (_jsxs(_Fragment, { children: [_jsx(DropdownMenuItem, { children: "Manage workspaces" }), _jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Settings" }), _jsx(DropdownMenuItem, { children: "Profile" })] })), _jsx(DropdownMenuSeparator, {}), _jsx(ThemeToggleMenuItem, {}), !useConfiguredItems && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsx(DropdownMenuItem, { children: "Sign out" })] })), resolvedFooter] })] }));
61
61
  }
62
62
  /* v8 ignore next — v8 phantom duplicate function record for AppTabs (default params) */
63
63
  export function AppTabs({ renderLink } = {}) {
@@ -91,21 +91,42 @@ export function AppTabs({ renderLink } = {}) {
91
91
  onMouseEnter: () => undefined, className: className, children: children }, app.id));
92
92
  }) }));
93
93
  }
94
- // ---------------------------------------------------------------------------
95
- // PanelToggle
96
- // ---------------------------------------------------------------------------
97
- export function PanelToggle() {
94
+ export function PanelToggle({ panelViews = [] } = {}) {
98
95
  const { panel } = useMedaShell();
99
96
  const isOpen = panel.mode !== 'closed';
100
- const handleClick = () => {
101
- panel.setMode(isOpen ? 'closed' : 'panel');
97
+ // No registered views a single button that just opens/closes the panel.
98
+ if (panelViews.length === 0) {
99
+ return (_jsx("button", { type: "button", "aria-label": isOpen ? 'Close right panel' : 'Open right panel', "aria-pressed": isOpen, onClick: () => panel.setMode(isOpen ? 'closed' : 'panel'), className: cn('inline-flex h-9 items-center gap-1.5 rounded-[10px] bg-accent px-2.5 text-muted-foreground transition-colors hover:text-foreground', isOpen && 'text-foreground'), children: _jsx(PanelRight, { size: 18, "aria-hidden": "true" }) }));
100
+ }
101
+ // Clicking a view toggles it: selecting the already-open view closes the
102
+ // panel; selecting any other view opens the panel focused on that view.
103
+ const handleSelect = (viewId) => {
104
+ if (isOpen && panel.activeView === viewId) {
105
+ panel.close();
106
+ }
107
+ else {
108
+ panel.focus(viewId);
109
+ }
102
110
  };
103
- return (_jsx("button", { type: "button", "aria-label": isOpen ? 'Close right panel' : 'Open right panel', onClick: handleClick, className: cn('inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors', isOpen ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent'), children: isOpen ? (_jsx(PanelRightClose, { size: 18, "aria-hidden": "true" })) : (_jsx(PanelRightOpen, { size: 18, "aria-hidden": "true" })) }));
111
+ // ONE grouped control: the whole pill (panel icon + chevron) is a single
112
+ // dropdown trigger — not two separate buttons.
113
+ return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: _jsx("button", { type: "button", "aria-label": "Right panel views", className: cn('inline-flex h-9 items-center gap-1.5 rounded-[10px] bg-accent px-2.5 text-muted-foreground transition-colors hover:text-foreground data-[popup-open]:text-foreground', isOpen && 'text-foreground') }), children: [_jsx(PanelRight, { size: 18, "aria-hidden": "true" }), _jsx(ChevronDown, { size: 14, "aria-hidden": "true" })] }), _jsxs(DropdownMenuContent, { align: "end", sideOffset: 8, className: "w-64 rounded-xl p-1.5", children: [_jsx("div", { className: "px-2 pt-1.5 pb-1 font-bold text-[10.5px] uppercase tracking-wider text-muted-foreground", children: "Panel views" }), panelViews.map((view) => {
114
+ const Icon = view.icon;
115
+ const active = isOpen && panel.activeView === view.id;
116
+ return (_jsxs(DropdownMenuItem, { onClick: () => handleSelect(view.id), className: cn('gap-2.5 rounded-[9px] px-2.5 py-2 text-sm', active && 'text-foreground'), children: [_jsx("span", { className: cn('grid h-[26px] w-[26px] shrink-0 place-items-center rounded-[7px] transition-colors', active ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'), children: Icon != null ? _jsx(Icon, { size: 16, "aria-hidden": "true" }) : null }), _jsx("span", { className: "flex-1 font-medium", children: view.label }), active ? _jsx("span", { className: "size-1.5 rounded-full bg-primary" }) : null] }, view.id));
117
+ })] })] }));
104
118
  }
105
119
  /* v8 ignore next — v8 phantom duplicate function record for ShellHeader (default params) */
106
- export function ShellHeader({ globalActions, headerCenter, appTabsRenderLink, className, workspaceMenuItems, workspaceMenuFooter, } = {}) {
120
+ export function ShellHeader({ globalActions, headerCenter, headerLeading, appTabsRenderLink, className, workspaceMenuItems, workspaceMenuFooter, showPanelToggle = true, panelViews = [], } = {}) {
107
121
  const band = useShellViewport();
108
122
  if (band === 'mobile')
109
123
  return null;
110
- return (_jsxs("header", { className: cn('flex h-[var(--shell-header-height)] w-full items-center justify-between', 'gap-3 border-b border-border bg-background px-3', className), children: [_jsx("div", { className: "flex shrink-0 items-center", children: _jsx(WorkspaceSwitcher, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }) }), _jsx("div", { className: "flex min-w-0 flex-1 items-center", children: headerCenter !== undefined ? headerCenter : _jsx(AppTabs, { renderLink: appTabsRenderLink }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [globalActions, _jsx(PanelToggle, {})] })] }));
124
+ // Leading content (e.g. section tabs) sits in the LEFT region just after
125
+ // the WorkspaceSwitcher — separated by whitespace only (no divider line),
126
+ // matching the design prototype's topbar spacing.
127
+ const leadingRegion = headerLeading != null ? (_jsx("div", { className: "ml-2 flex min-w-0 items-center", children: headerLeading })) : null;
128
+ if (headerCenter !== undefined) {
129
+ return (_jsxs("header", { className: cn('grid h-[var(--shell-header-height)] w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center', 'gap-4 bg-background px-4', className), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2 justify-self-start", children: [_jsx(WorkspaceSwitcher, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }), leadingRegion] }), _jsx("div", { className: "flex min-w-0 items-center justify-center justify-self-center", children: headerCenter }), _jsxs("div", { className: "flex min-w-0 items-center justify-end gap-2 justify-self-end", children: [globalActions, showPanelToggle && _jsx(PanelToggle, { panelViews: panelViews })] })] }));
130
+ }
131
+ return (_jsxs("header", { className: cn('flex h-[var(--shell-header-height)] w-full items-center justify-between', 'gap-4 bg-background px-4', className), children: [_jsxs("div", { className: "flex min-w-0 shrink-0 items-center gap-2", children: [_jsx(WorkspaceSwitcher, { menuItems: workspaceMenuItems, menuFooter: workspaceMenuFooter }), leadingRegion] }), _jsx("div", { className: "flex min-w-0 flex-1 items-center", children: _jsx(AppTabs, { renderLink: appTabsRenderLink }) }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [globalActions, showPanelToggle && _jsx(PanelToggle, { panelViews: panelViews })] })] }));
111
132
  }
@@ -7,5 +7,5 @@ const layoutClass = {
7
7
  fullbleed: 'w-full',
8
8
  };
9
9
  export function ShellMain({ layout = 'workspace', className, children }) {
10
- return (_jsx("main", { "data-meda-shell-main-layout": layout, className: cn('flex-1 min-w-0 overflow-y-auto bg-shell-main', '[content-visibility:auto]', layoutClass[layout], className), children: children }));
10
+ return (_jsx("main", { "data-meda-shell-main-layout": layout, className: cn('flex-1 min-w-0 overflow-y-auto bg-shell-main', layoutClass[layout], className), children: children }));
11
11
  }
@@ -99,12 +99,14 @@ export interface AppShellIconRailConfig {
99
99
  footer?: ReactNode;
100
100
  activeId?: string;
101
101
  renderLink?: import('./icon-rail.js').IconRailProps['renderLink'];
102
+ labelVisibility?: import('./icon-rail.js').IconRailProps['labelVisibility'];
102
103
  }
103
104
  /** ContextRail configuration for `<AppShell variant="workspace">`. */
104
105
  export interface AppShellContextRailConfig {
105
106
  appId: string;
106
107
  module: ContextModule;
107
108
  activeItemId?: string;
109
+ renderLink?: import('./context-rail.js').ContextRailProps['renderLink'];
108
110
  header?: ContextRailHeader;
109
111
  scroll?: ContextRailScroll;
110
112
  }
@@ -1,4 +1,5 @@
1
1
  @import "./tokens.css";
2
+ @import "@xyflow/react/dist/style.css";
2
3
 
3
4
  /* Tell Tailwind v4 to scan meda's compiled component output so utilities
4
5
  like `h-full`, `mt-auto`, `py-3.5`, and `bg-shell-rail` (used by IconRail,
@@ -236,8 +236,10 @@
236
236
  --shell-panel: var(--color-neutral-50);
237
237
  --shell-border: var(--color-neutral-200);
238
238
  --shell-shadow: 0 4px 12px hsl(0 0% 0% / 0.04);
239
- --shell-header-height: 56px;
239
+ --shell-header-height: 64px;
240
+ --shell-mobile-header-height: 48px;
240
241
  --shell-rail-width: 60px;
242
+ --shell-rail-label-width: 88px;
241
243
  --shell-context-default: 300px;
242
244
  --shell-context-min: 240px;
243
245
  --shell-context-max: 420px;
@@ -1,5 +1,4 @@
1
1
  import { type Connection, type EdgeChange, type NodeChange, type ReactFlowProps } from '@xyflow/react';
2
- import '@xyflow/react/dist/style.css';
3
2
  import type { WorkflowEdge, WorkflowNode } from './types.js';
4
3
  export interface WorkflowCanvasProps {
5
4
  nodes: WorkflowNode[];
@@ -1,7 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Background, BackgroundVariant, Controls, ReactFlow, } from '@xyflow/react';
4
- import '@xyflow/react/dist/style.css';
5
4
  import { useCallback } from 'react';
6
5
  import { cn } from '../lib/utils.js';
7
6
  import { workflowEdgeTypes } from './edges/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medalsocial/meda",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "description": "Shared Meda UI shell and runtime package.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -20,7 +20,11 @@
20
20
  "files": [
21
21
  "README.md",
22
22
  "components.json",
23
- "dist"
23
+ "dist",
24
+ "skills",
25
+ "!skills/_artifacts",
26
+ "!skills/**/_artifacts",
27
+ "!skills/**/_artifacts/**"
24
28
  ],
25
29
  "exports": {
26
30
  ".": {
@@ -191,6 +195,7 @@
191
195
  "@storybook/addon-vitest": "10.3.6",
192
196
  "@storybook/react-vite": "10.3.6",
193
197
  "@tailwindcss/vite": "4.2.4",
198
+ "@tanstack/intent": "0.0.41",
194
199
  "@testing-library/jest-dom": "6.9.1",
195
200
  "@testing-library/react": "16.3.2",
196
201
  "@types/react": "19.2.14",
@@ -220,6 +225,9 @@
220
225
  "vitest-axe": "0.1.0",
221
226
  "wrangler": "4.87.0"
222
227
  },
228
+ "keywords": [
229
+ "tanstack-intent"
230
+ ],
223
231
  "scripts": {
224
232
  "build": "pnpm exec tsc -p tsconfig.build.json && node ./scripts/build.mjs",
225
233
  "lint": "biome check .",
@@ -0,0 +1,198 @@
1
+ ---
2
+ name: brand
3
+ description: Use when picking a CTA color, choosing brand-vs-semantic tokens, building a button/badge/pill, styling a chart or sidebar, configuring focus rings, editing any file under `src/styles/`, or scaffolding theme tokens in a new app that consumes `@medalsocial/meda`. Required reading before any visual work — covers the constant-brand CTA rule, the full token contract, and Tailwind v4 wiring.
4
+ ---
5
+
6
+ # Meda Brand & Tokens
7
+
8
+ ## When to load this skill
9
+
10
+ - Editing `tokens.css`, `theme.css`, `globals.css`, or any file under `src/styles/`.
11
+ - Choosing a color for a CTA, link, badge, status pill, sidebar item, or chart series.
12
+ - Adding, renaming, or removing a token.
13
+ - Picking typography (font family, weight, size scale).
14
+ - Configuring focus rings, radii, or spacing.
15
+ - Wiring a brand logo or accent color in a consumer app.
16
+
17
+ ## Overview
18
+
19
+ `@medalsocial/meda` is **purple-forward with a zinc-aligned neutral base** — not a custom warm/cool scheme. Two coexisting layers of tokens:
20
+
21
+ - **Primitive ramps** — `brand-*`, `neutral-*`, `error-*`, `info-*`, `success-*`, `warning-*`, plus dark-mode `surface-*` constants. Constant across themes.
22
+ - **Semantic tokens** — `--primary`, `--accent`, `--background`, `--card`, `--sidebar`, `--chart-1`...`--chart-5`, `--text-*`. Flip by theme.
23
+
24
+ Most components reference semantic tokens. Primary CTAs and brand-identity surfaces pin to the brand ramp directly — see the rule below.
25
+
26
+ CSS framework: **Tailwind v4**, CSS-first via `@theme inline` in `src/styles/theme.css`. **No `tailwind.config.js`**, no JS preset. Primitives are exposed as Tailwind theme keys (`--color-brand-*`, `--color-neutral-*`, `--color-chart-*`, etc.), so `bg-brand-500`, `text-success-600`, `bg-chart-3` work natively. Dark mode uses the Tailwind `dark:` variant on the `<html>` root.
27
+
28
+ ## Brand ramp (constant across themes — purple, 11 stops)
29
+
30
+ | Stop | Hex |
31
+ |---|---|
32
+ | brand-50 | `#FAFAFD` |
33
+ | brand-100 | `#EEEAF5` |
34
+ | brand-200 | `#DCD4E8` |
35
+ | brand-300 | `#B8A3D2` |
36
+ | brand-400 | `#9A6AC2` |
37
+ | brand-500 | `#7E3FAC` |
38
+ | brand-600 | `#6A2E96` |
39
+ | brand-700 | `#482070` |
40
+ | brand-800 | `#2F1552` |
41
+ | brand-900 | `#1C0E38` |
42
+ | brand-950 | `#120A24` |
43
+
44
+ ## The constant-brand CTA rule (critical)
45
+
46
+ **Primary CTAs and brand-identity surfaces use `bg-brand-500 hover:bg-brand-600 text-white` in BOTH light and dark themes. Do NOT use `bg-primary` / `text-primary-foreground` for primary CTAs.**
47
+
48
+ **Why:** `--primary` deliberately flips — `brand-800` in light, `brand-100` in dark. `brand-100` is a pale near-white, so `bg-primary` renders a primary CTA as a washed-out near-white pill in dark mode. Medal's brand identity is the rich `#7E3FAC` purple applied constantly, not theme-inverted.
49
+
50
+ **`bg-primary` is fine when used as a tint/overlay** (e.g. `bg-primary/12 text-primary` for an active rail item — see the `shell` skill). The prohibition is specifically about solid CTA fills where the user expects a vivid brand presence.
51
+
52
+ ```tsx
53
+ // ❌ Wrong: pale in dark
54
+ <Button className="bg-primary text-primary-foreground">Get started</Button>
55
+
56
+ // ✅ Right: constant brand-purple in both themes
57
+ <Button className="bg-brand-500 hover:bg-brand-600 text-white">Get started</Button>
58
+
59
+ // ✅ Also right (active state, tinted overlay)
60
+ <Link className="bg-primary/12 text-primary">Dashboard</Link>
61
+ ```
62
+
63
+ Logos and brand marks render in `var(--color-brand-500)`, not `currentColor`.
64
+
65
+ ## Neutral ramp (zinc-aligned)
66
+
67
+ | Stop | Hex | Tailwind zinc |
68
+ |---|---|---|
69
+ | neutral-50 | `#FAFAFA` | zinc-50 |
70
+ | neutral-100 | `#F4F4F5` | zinc-100 |
71
+ | neutral-200 | `#E4E4E7` | zinc-200 |
72
+ | neutral-300 | `#A1A1AA` | zinc-400 |
73
+ | neutral-400 | `#71717A` | zinc-500 |
74
+ | neutral-500 | `#52525B` | zinc-600 |
75
+ | neutral-600 | `#3F3F46` | zinc-700 |
76
+ | neutral-700 | `#27272A` | zinc-800 |
77
+ | neutral-800 | `#18181B` | zinc-900 |
78
+ | neutral-900 | `#111113` | between zinc-900/950 |
79
+ | neutral-950 | `#09090B` | zinc-950 |
80
+
81
+ **Do not** ship a generic gray neutral palette (Apple-style cool grays, Tailwind `gray-*`). Use the zinc-aligned ramp.
82
+
83
+ ## Status ramps
84
+
85
+ | Status | Family | Mid (600) |
86
+ |---|---|---|
87
+ | error | rose | `#E11D48` |
88
+ | info | blue | `#2563EB` |
89
+ | **success** | **teal**, not green | `#0D9488` |
90
+ | warning | amber | `#D97706` |
91
+
92
+ Each ramp has 10 stops (50, 100–900).
93
+
94
+ ## Surface primitives (dark-mode constants)
95
+
96
+ - `--surface-bg`: `#09090B`
97
+ - `--surface-card`: `#18181B`
98
+ - `--surface-muted`: `#27272A`
99
+ - `--surface-border`: `#2E2E33`
100
+ - `--surface-sidebar`: `#0F0F12`
101
+
102
+ ## Semantic tokens (flip by theme)
103
+
104
+ ### shadcn-shape core
105
+
106
+ | Token | Light | Dark |
107
+ |---|---|---|
108
+ | `--background` | `neutral-50` | `surface-bg` |
109
+ | `--foreground` | `neutral-950` | `neutral-50` |
110
+ | `--card` | `neutral-50` | `surface-card` |
111
+ | `--card-foreground` | `neutral-950` | `neutral-50` |
112
+ | `--popover` | `neutral-50` | `surface-card` |
113
+ | **`--primary`** | **`brand-800`** | **`brand-100`** |
114
+ | `--primary-foreground` | `brand-50` | `brand-900` |
115
+ | `--secondary` | `neutral-200` | `surface-muted` |
116
+ | `--muted` | `neutral-100` | `surface-muted` |
117
+ | `--muted-foreground` | `neutral-500` | `neutral-300` |
118
+ | `--accent` | `neutral-200` | `surface-muted` |
119
+ | `--destructive` | `error-600` | `error-400` |
120
+ | `--border` | `neutral-200` | `surface-border` |
121
+ | `--input` | `neutral-200` | `surface-muted` |
122
+ | `--ring` | `brand-600` | `brand-400` |
123
+
124
+ **Critical:** `--accent` is a NEUTRAL by contract (not a brand color). Use `brand-*` tokens directly when you want brand color. `--ring` (focus ring) is brand-keyed and theme-flips between brand-600 and brand-400.
125
+
126
+ ### Status (semantic)
127
+
128
+ | Token | Light | Dark |
129
+ |---|---|---|
130
+ | `--success` / `--success-foreground` | `success-600` / `neutral-950` | `success-400` / `neutral-950` |
131
+ | `--warning` / `--warning-foreground` | `warning-600` / `neutral-950` | `warning-400` / `neutral-950` |
132
+ | `--info` / `--info-foreground` | `info-600` / `neutral-50` | `info-400` / `neutral-950` |
133
+ | `--danger` (alias for destructive) | `error-600` | `error-400` |
134
+
135
+ ### Sidebar
136
+
137
+ | Token | Light | Dark |
138
+ |---|---|---|
139
+ | `--sidebar` | `neutral-100` | `surface-sidebar` |
140
+ | `--sidebar-foreground` | `neutral-950` | `neutral-50` |
141
+ | `--sidebar-primary` | `brand-700` | `brand-400` |
142
+ | `--sidebar-accent` | `neutral-50` | `surface-card` |
143
+ | `--sidebar-border` | `neutral-200` | `surface-border` |
144
+ | `--sidebar-ring` | `brand-500` | `brand-400` |
145
+
146
+ ### Chart (5 series)
147
+
148
+ | Token | Light | Dark | Role |
149
+ |---|---|---|---|
150
+ | `--chart-1` | `brand-600` | `brand-500` | Brand purple |
151
+ | `--chart-2` | `info-600` | `info-500` | Blue |
152
+ | `--chart-3` | `success-600` | `success-400` | Teal |
153
+ | `--chart-4` | `warning-600` | `warning-500` | Amber |
154
+ | `--chart-5` | `error-600` | `error-400` | Rose |
155
+
156
+ Always cycle in this order — `--chart-1` first — so brand purple owns the primary series across consumers.
157
+
158
+ ### Text
159
+
160
+ | Token | Light | Dark |
161
+ |---|---|---|
162
+ | `--text` | `neutral-950` | `neutral-50` |
163
+ | `--text-muted` | `neutral-500` | `neutral-300` |
164
+ | `--text-link` | `brand-600` | `brand-400` |
165
+ | `--text-primary` | `neutral-950` | `neutral-100` |
166
+ | `--text-secondary` | `neutral-600` | `neutral-300` |
167
+
168
+ ## Typography — Geist only
169
+
170
+ - `--font-family-sans`: **`"Geist"`**
171
+ - `--font-family-mono`: **`"Geist Mono"`**
172
+
173
+ **No Inter, no system stacks.** Geist + Geist Mono only.
174
+
175
+ Size scale: `display` 36, `h1` 30, `h2` 24, `h3` 20, `h4` 18, `body-lg` 16, `body` 14, `body-sm` 13, `caption` 11, `overline` 10.
176
+
177
+ ## Radii
178
+
179
+ `--radius` (base) 8px. Scale: `radius-sm` 4, `radius-md` 6, `radius-lg` 8, `radius-xl` 12, `radius-2xl` 16, `radius-3xl` 20, `radius-4xl` 24, `radius-full` 9999.
180
+
181
+ ## Spacing
182
+
183
+ `xs` 4, `sm` 8, `md` 12, `lg` 16, `xl` 24, `2xl` 32, `3xl` 48, `4xl` 64 (px). Use the named tokens, not raw px values, so consumer overrides flow through.
184
+
185
+ ## Anti-patterns
186
+
187
+ | Anti-pattern | Why it's wrong | Correct approach |
188
+ |---|---|---|
189
+ | `bg-primary` for a primary CTA | `--primary` flips to pale brand-100 in dark — washed-out CTAs | `bg-brand-500 text-white` (constant) |
190
+ | Generic gray neutrals (Tailwind `gray-*`) | Medal is zinc-aligned, not cool gray | Use `neutral-*` from the ramp |
191
+ | `--primary` mapped to brand-500 | Lib has primary as brand-800 light / brand-100 dark | Map exactly — light=brand-800, dark=brand-100 |
192
+ | Treating `--accent` as a brand color | `--accent` is neutral by contract | Use `brand-*` tokens directly |
193
+ | Hard-coded `ring-blue-500` for focus rings | Focus rings are brand-keyed via `--ring` | `focus-visible:ring-2 focus-visible:ring-ring` |
194
+ | Picking chart colors ad-hoc per app | Loses cross-consumer parity | Cycle `--chart-1`..`--chart-5` in order |
195
+ | Adding Inter or system fonts | Canonical is Geist + Geist Mono | Stick to the two families |
196
+ | `success` as green | Canonical success is teal | Use the teal scale |
197
+ | `[...].join(' ')` for classNames | Noisy, error-prone | Use a consumer-owned `cn()` helper — see the `components` skill |
198
+ | Raw px (e.g. `pt-[12px]`) for spacing | Bypasses consumer overrides | Use the named spacing tokens |
@@ -0,0 +1,196 @@
1
+ ---
2
+ name: components
3
+ description: Use when implementing a UI component in any app consuming `@medalsocial/meda` — building a card, dialog, status pill, empty state, dropdown, tooltip — or picking a primitive vs a shadcn-style adapter vs a domain folder. Required reading before generating any new visual code; the `meda-storybook` MCP server is the source of truth for what already exists.
4
+ ---
5
+
6
+ # Meda Components
7
+
8
+ ## When to load this skill
9
+
10
+ - About to author or modify a React component that renders UI.
11
+ - Choosing where a new component lives (primitive / ui-adapter / domain / recipe).
12
+ - Composing `Card`, `EmptyState`, `StatusPill`, or another exported primitive.
13
+ - Picking between primitives and shadcn-style ui adapters.
14
+ - Writing or updating a `*.stories.tsx`.
15
+ - Importing `MarkdownView` or another optional-peer-deps component.
16
+
17
+ ## Look up first, generate second
18
+
19
+ **Before writing any new visual component, query the `meda-storybook` MCP server.** It exposes the running Storybook (`pnpm storybook` on `http://localhost:6006`) and is the authoritative index of what already exists. Many "new" components are existing primitives composed differently.
20
+
21
+ If the MCP is not running, start it:
22
+
23
+ ```bash
24
+ pnpm storybook
25
+ ```
26
+
27
+ The MCP is registered in `.mcp.json`.
28
+
29
+ ## Where things live
30
+
31
+ | Folder | What it is |
32
+ |---|---|
33
+ | `src/primitives/` | Standalone visual building blocks. Each owns its own styling. |
34
+ | `src/components/ui/` | shadcn-style adapters that wrap `@base-ui/react`. |
35
+ | `src/shell/` | Layout regions — see the `shell` skill. |
36
+ | `src/{chat,kanban,calendar,timeline,marketing,workflow-builder,...}/` | Domain folders for higher-level composed surfaces. |
37
+ | `src/recipes/` | Framework-specific glue (e.g. `recipes/next.ts`). |
38
+ | `src/lib/` | Shared utilities (`cn()`, etc.). |
39
+
40
+ **Don't invent a new top-level layer** (primitives/ui/recipes). Adding a new *domain* folder for a real product area is fine; check with a maintainer first if uncertain.
41
+
42
+ ## Primitive vs ui-adapter vs domain — picking where to add
43
+
44
+ | If you need… | Reach for | Example |
45
+ |---|---|---|
46
+ | A self-contained visual element with no behavior dependency | `src/primitives/` | `Card`, `StatusPill`, `EmptyState`, `Skeleton`, `FilterRail` |
47
+ | An overlay / menu / form control wrapping `@base-ui/react` | `src/components/ui/` | `DropdownMenu`, `Dialog`, `Drawer`, `Tooltip`, `Command`, `Checkbox`, `Collapsible` |
48
+ | A composed surface tied to a product area | A domain folder | A chat thread (`src/chat/`), a kanban column (`src/kanban/`) |
49
+ | Framework-specific glue | `src/recipes/` | `recipes/next.ts` |
50
+
51
+ ## Primitives layer
52
+
53
+ **`@base-ui/react` is the canonical primitives layer.** Forbidden in new code: `@radix-ui/*` packages. shadcn-style adapters in `src/components/ui/` wrap `@base-ui/react` — follow the existing patterns in `dropdown-menu.tsx` / `tooltip.tsx`.
54
+
55
+ ## Card — compound component
56
+
57
+ ```tsx
58
+ import { Card } from '@medalsocial/meda';
59
+
60
+ <Card>
61
+ <Card.Header>
62
+ <h3 className="text-h4">Workspace usage</h3>
63
+ </Card.Header>
64
+ <Card.Body>
65
+ Body content here.
66
+ </Card.Body>
67
+ <Card.Footer>
68
+ <Button variant="ghost">Cancel</Button>
69
+ <Button>Save</Button>
70
+ </Card.Footer>
71
+ </Card>
72
+ ```
73
+
74
+ `Card`, `Card.Header`, `Card.Body`, `Card.Footer` are all `<div>`-typed and accept any standard div props. The header/footer auto-hide their borders when adjacent to the body (`last:border-b-0` / `first:border-t-0`), so a header-only or body-only card has no orphan border. Always use the subcomponents — don't reach into the card with raw padded `<div>`s.
75
+
76
+ ## EmptyState — variants matter
77
+
78
+ ```tsx
79
+ import { EmptyState } from '@medalsocial/meda';
80
+ import { Inbox } from 'lucide-react';
81
+
82
+ <EmptyState
83
+ icon={Inbox}
84
+ title="No messages yet"
85
+ description="When someone messages your workspace, it'll show up here."
86
+ action={<Button>Invite teammates</Button>}
87
+ variant="panel"
88
+ />
89
+ ```
90
+
91
+ Variants:
92
+ - `'default'` — full-page empty state, generous vertical spacing.
93
+ - `'panel'` — panel-sized empty state (e.g. inside a right panel or card).
94
+ - `'inline'` — compact, for list-row or table-row empty states.
95
+
96
+ The `icon` prop accepts a Lucide component (`Inbox`) OR a rendered element (custom inline SVG). Lucide components are auto-sized; rendered elements are placed in an `inline-flex` span and you control their size.
97
+
98
+ ## StatusPill — solid tones, two sizes
99
+
100
+ ```tsx
101
+ import { StatusPill } from '@medalsocial/meda';
102
+
103
+ <StatusPill tone="success">Synced</StatusPill>
104
+ <StatusPill tone="warning" size="md">Action required</StatusPill>
105
+ <StatusPill tone="danger" dot={false}>Failed</StatusPill>
106
+ ```
107
+
108
+ - `tone`: `'neutral' | 'info' | 'success' | 'warning' | 'danger'`
109
+ - `size`: `'sm'` (default, 11px text) or `'md'` (12px text)
110
+ - `dot`: `true` (default) to show a leading dot
111
+
112
+ **Why solid backgrounds (not tinted):** the 11px small size needs ≥4.5:1 contrast to pass WCAG AA. Tinted backgrounds (`bg-info/15 text-info`) fail axe gates at this size. Solid `bg-info text-info-foreground` etc. always passes via the theme tokens. If you need a tinted variant, use it at `size="md"` or larger only, and verify with `vitest-axe`.
113
+
114
+ ## ui-adapters — NOT exported from the npm package
115
+
116
+ shadcn-style adapters in `src/components/ui/` (`Dialog`, `DropdownMenu`, `Tooltip`, `Drawer`, `Command`, `Checkbox`, `Collapsible`) wrap `@base-ui/react`. **They are intentionally NOT exported from `@medalsocial/meda`** — meda follows the shadcn philosophy: you own the ui code.
117
+
118
+ Two supported ways to consume them:
119
+
120
+ 1. **Vendor via the meda shadcn registry** (recommended for shell-consistent apps). meda publishes a registry at `https://meda.medalsocial.com/r/...` — install via the shadcn CLI:
121
+
122
+ ```bash
123
+ npx shadcn@latest add https://meda.medalsocial.com/r/<component>.json
124
+ ```
125
+
126
+ The components land in your app's `@/ui` folder (configurable via your `components.json` aliases). You own and can modify the code.
127
+
128
+ 2. **Write your own wrapping `@base-ui/react` directly.** When you need a primitive meda doesn't provide a recipe for, follow the patterns visible in meda's `src/components/ui/dropdown-menu.tsx` / `tooltip.tsx` and ship the result in your own `@/ui` folder.
129
+
130
+ Do NOT try `import { Dialog } from '@medalsocial/meda'` — it will fail with a missing-export error.
131
+
132
+ ## Optional peer dependencies — MarkdownView pattern
133
+
134
+ `MarkdownView` has optional peers (`react-markdown`, `remark-gfm`, `rehype-highlight`). It is **intentionally not re-exported from `src/primitives/index.ts`** — even type re-exports cause TS to follow the chain and break consumers who haven't installed the peers. Import via the dedicated subpath:
135
+
136
+ ```ts
137
+ import { MarkdownView, type MarkdownViewProps } from '@medalsocial/meda/markdown-view';
138
+ ```
139
+
140
+ When you add another optional-peer component, follow this pattern: own subpath, no root re-export, peers declared as `peerDependenciesMeta.*.optional`.
141
+
142
+ ## Storybook discipline
143
+
144
+ **Every exported component has a `*.stories.tsx`.** New components without stories fail review.
145
+
146
+ Stories live next to the component (`card.tsx` + `card.stories.tsx`). Use the `__stories__/` folder only for cross-component demos (full shell stories, theme-switcher demos).
147
+
148
+ Visual snapshots run through Chromatic on every PR.
149
+
150
+ ## Icons
151
+
152
+ **Lucide React only.** No other icon libraries in new code. Never use emojis as UI icons. Custom-drawn icons require explicit review.
153
+
154
+ Shell sizes: 22px (rails), 16px (header), 14px (inline). Inside primitives like `EmptyState`, the icon auto-sizes to match the variant.
155
+
156
+ ## className composition
157
+
158
+ Do not use `[...].join(' ')` or template-string concatenation for classes — noisy, merge-unsafe.
159
+
160
+ meda uses a `cn()` helper internally (`src/lib/utils.ts`) built from `clsx` + `tailwind-merge`, but it is **not exported from the package**. In consumer apps, declare your own — first install the two deps explicitly (don't rely on them being transitive — that's not safe under pnpm strict, Yarn PnP, or any layout that hides un-declared dependencies):
161
+
162
+ ```bash
163
+ pnpm add clsx tailwind-merge
164
+ ```
165
+
166
+ ```ts
167
+ // your-app/src/lib/utils.ts
168
+ import { type ClassValue, clsx } from 'clsx';
169
+ import { twMerge } from 'tailwind-merge';
170
+
171
+ export function cn(...inputs: ClassValue[]): string {
172
+ return twMerge(clsx(inputs));
173
+ }
174
+ ```
175
+
176
+ ```tsx
177
+ import { cn } from '@/lib/utils';
178
+
179
+ <div className={cn('base classes', isActive && 'active', className)} />
180
+ ```
181
+
182
+ ## Anti-patterns
183
+
184
+ | Anti-pattern | Why it's wrong | Correct approach |
185
+ |---|---|---|
186
+ | Generating UI without checking Storybook first | Duplicates existing primitives | Query `meda-storybook` MCP first |
187
+ | `@radix-ui/*` in new code | Deprecated primitives layer | Wrap `@base-ui/react` in `src/components/ui/` |
188
+ | Re-exporting `MarkdownView` from root | Breaks consumers without optional peers | Import from `@medalsocial/meda/markdown-view` |
189
+ | Tinted `StatusPill` at `size="sm"` | Fails WCAG AA at 11px (vitest-axe gate) | Use solid tones at `sm`; tints only at `md`+ with a11y verification |
190
+ | Raw `<div>` inside `<Card>` with custom padding | Bypasses border-collapse logic; off-grid spacing | Use `Card.Header` / `Card.Body` / `Card.Footer` |
191
+ | `import { Dialog } from '@medalsocial/meda'` | Not exported — meda follows the shadcn "you own the ui" model | Vendor via `npx shadcn@latest add https://meda.medalsocial.com/r/...` or write your own wrapping `@base-ui/react` |
192
+ | `import { cn } from '@medalsocial/meda'` | Not exported | Declare your own three-line `cn()` using `clsx` + `tailwind-merge` (already transitive) |
193
+ | Adding emojis as UI icons | Inconsistent sizing + brand | Use Lucide equivalents (`Clock`, `Check`, `AlertTriangle`) |
194
+ | Inventing a new top-level layer folder | Loses the established taxonomy | Use primitives / ui / domain / recipes |
195
+ | Skipping `*.stories.tsx` for an exported component | Breaks the lookup-first discipline for the next contributor | Always ship a story |
196
+ | `[...].join(' ')` for classes | Error-prone | Use `cn()` |