Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ const preview: Preview = {
(Story, context) => {
const theme = context.globals.theme || 'dark';
const bgColor = theme === 'light' ? '#f8fafc' : '#020916';
// El tema también va en <html>: lo portaleado (DropdownMenu, Tooltip, Sheet, Toast,
// popovers) se monta en <body>, FUERA del wrapper, y sin esto siempre salía oscuro.
// Es el mismo lugar donde lo pone una app real (Fovente: <html data-theme>).
if (typeof document !== 'undefined') {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
root.classList.toggle('dark', theme === 'dark');
root.style.colorScheme = theme;
}
const isFullscreen = context.parameters.layout === 'fullscreen';

// For fullscreen layouts, use minimal wrapper
Expand Down
18 changes: 16 additions & 2 deletions COMPONENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @cofoundy/ui — Component Catalog

**Last updated:** 2026-09-25 · **Storybook:** [ui.cofoundy.dev](https://ui.cofoundy.dev) · **Total exports:** ~95 (`chat-sim` is a separate subpath, not yet in this count — see its section)
**Last updated:** 2026-09-29 · **Storybook:** [ui.cofoundy.dev](https://ui.cofoundy.dev) · **Total exports:** ~106 (`chat-sim` is a separate subpath, not yet in this count — see its section)

> **For agents:** start at the [Intent Map](#intent-map) below — maps "I need X" → component name + import + story URL. If you don't find your need there, fall through to the [Section tables](#chat) (alphabetical) or run `grep '^export' ~/cofoundy/packages/ui/src/index.ts` for the raw export list. Storybook auto-discovery: `curl -sS https://ui.cofoundy.dev/index.json` returns 598 entries (all stories, parseable JSON).

Expand Down Expand Up @@ -60,6 +60,15 @@ Most common needs first. Story URL pattern: `https://ui.cofoundy.dev/?path=/docs
| Switch (two-edge knob, `size="lg"` 48×28) / Separator | `Switch`, `switchVariants`, `Separator` | UI | `ui-switch`, `ui-separator` |
| Logo (3D cube isologo) | `Logo` | UI | `ui-logo` |
| Dropdown nav (mega-menu-lite) | `NavDropdown` | Navigation | `navigation-navdropdown` |
| App rail (icons, traveling active indicator, optional expand-on-hover) | `NavRail`, `NavRailItem`, `NavRailSection` | Navigation | `navigation-navrail` |
| Rail items that don't fit collapse into «+N» | `NavRailOverflow`, `NavRailOverflowRow` | Navigation | `navigation-navrailoverflow` |
| Mobile bottom tab bar (≤5) | `TabBar`, `TabBarItem` | Navigation | `navigation-tabbar` |
| Mobile «Más» bottom sheet | `MoreSheet` | Navigation | `navigation-moresheet` |
| Workspace / tenant switcher (rail + sheet) | `WorkspaceSwitcher`, `WorkspaceMark` | Navigation | `navigation-workspaceswitcher` |
| «New» marker (dot / chip, enters once) | `NewDot`, `NewChip` | Navigation | `navigation-newindicator` |
| Installable plugin card + detail sheet | `PluginCard`, `PluginSheet` | UI | `ui-plugincard` |
| Status dot + label that morphs between states | `StatusPill` | UI | `ui-statuspill` |
| Form generated from JSON Schema + `x-ui` hints | `SchemaForm` | UI | `ui-schemaform` |
| Hero with animated WebGL gradient + poster fallback | `ShaderHero` | Hero | `hero-shaderhero` |
| Transactional email template (rendered server-side) | see [Email](#email) (9 templates) | Email | `email-*` |
| **Docs MDX components** (allowlist for `docs-ai/content/*`) | see [Docs](#docs) (15 components) | Docs | `docs-*` |
Expand Down Expand Up @@ -179,7 +188,6 @@ shadcn/ui base, theme-tokenized.

`Button`, `Input`, `Badge`, `ChannelBadge`, `Avatar`, `Spinner`, `Skeleton`, `Switch`, `Separator`, `Tabs`, `Tooltip`, `Collapsible`, `Select`, `DropdownMenu`, `Sheet`, `Dialog`, `Breadcrumb`, `Sidebar` (+ ~20 subcomponents), `Toaster`/`toast`, `ConfirmDialog`, `DangerZone`/`DangerZoneItem`/`DangerZoneHeader`, `RoleChip`, `ActivationNote`, `ComposerQuickAction`, `Logo`, `LogoHeader`/`Wordmark`, `CofoundyBadge`, `ChannelBadge`, `CalBookingButton`/`CalendlyButton` (legacy alias), `NotFound`, `ThemeSwitcher`, `ShimmerText`, `GradientBorder`.

**App shell** — `WorkspaceShell` (+ `Identity`, `Nav`, `NavItem`, `Rail`, `RailFooter`, `Well` slots): full product-app layout chrome (rail + nav + content well).

**Command palette** — `CommandPalette` + `CommandPaletteTrigger`: ⌘K-style fuzzy command launcher. Styles eager-inject so the trigger renders styled on first paint.

Expand Down Expand Up @@ -210,6 +218,12 @@ For **brand identity work** (badge, logo, attribution) use the `/branding` skill
| Component | Description |
|---|---|
| `NavDropdown` | Mega-menu-lite dropdown for top nav. |
| `NavRail` / `NavRailItem` / `NavRailSection` | Vertical icon rail; active indicator travels (`layoutId`, edge spring); `expandOnHover` overlays labels, `pinned` reserves width; `isNew`, `badge`. |
| `NavRailOverflow` | Measures available height (ResizeObserver) and folds the rest into «+N»; popover stays inside the viewport. |
| `TabBar` / `TabBarItem` | Mobile bottom bar, two-edge active pill, badge pop, safe-area. |
| `MoreSheet` | Bottom sheet over `Sheet`: optional header, sections with kicker, cascading rows. |
| `WorkspaceSwitcher` / `WorkspaceMark` | Tenant switcher: single-shape mark + solid rotating badge, traveling hover/✓, `onSelect` after the ✓ lands. Variants `rail` / `sheet`. |
| `NewDot` / `NewChip` | «New» marker that enters once and never pulses. |

### Email

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"exports": {
".": "./src/index.ts",
"./styles": "./src/styles/index.css",
"./motion.css": "./src/styles/motion.css",
"./utils": "./src/utils/index.ts",
"./hero-shader": "./src/components/hero-shader/index.ts",
"./scripts/reveal-on-scroll": "./src/scripts/reveal-on-scroll.ts",
Expand Down
4 changes: 2 additions & 2 deletions scripts/gen-spring-tokens.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Writes the --cf-spring-* tokens into src/styles/index.css from SPRINGS (src/lib/spring.ts),
* Writes the --cf-spring-* tokens into src/styles/motion.css from SPRINGS (src/lib/spring.ts),
* between the GEN:SPRINGS markers. `--check` fails if the CSS drifted from the presets.
*
* npm run gen:springs # regenerate
Expand All @@ -9,7 +9,7 @@ import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { SPRINGS, springToCSSLinear } from "../src/lib/spring";

const CSS = resolve(import.meta.dirname, "../src/styles/index.css");
const CSS = resolve(import.meta.dirname, "../src/styles/motion.css");
const START = " /* GEN:SPRINGS:START — generated by scripts/gen-spring-tokens.ts, do not edit */";
const END = " /* GEN:SPRINGS:END */";

Expand Down
45 changes: 43 additions & 2 deletions src/__tests__/components/Button.status.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import { Button } from "../../components/ui/button";
* Markup captured from origin/main BEFORE `status` existed (2026-09-25). Without `status` the
* Button must render byte-for-byte the same — Fovente, TimelyAI and Landing depend on it.
*/
/**
* The ONE sanctioned delta since that baseline (slot 14, 2026-09-29): the press. `transition-all`
* (Tailwind's default cubic-bezier) became a snappy-spring transition plus `active:scale-[.96]`.
* Every other byte stays pinned.
*/
const PRESS =
"transition-[color,background-color,border-color,box-shadow,scale] duration-[var(--cf-spring-snappy-duration)] ease-[var(--cf-spring-snappy)] active:scale-[.96] active:duration-[var(--cf-duration-press)] motion-reduce:transition-none motion-reduce:active:scale-100";
const withPress = (html: string) => html.replace("transition-all", PRESS);

const BASELINE = {
plain:
"<button data-slot=\"button\" data-variant=\"default\" data-size=\"default\" class=\"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&amp;_svg]:pointer-events-none [&amp;_svg:not([class*=&#x27;size-&#x27;])]:size-4 shrink-0 [&amp;_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] bg-[var(--primary)] text-[var(--primary-foreground)] hover:bg-[var(--primary)]/90 h-9 px-4 py-2 has-[&gt;svg]:px-3\">Guardar</button>",
Expand Down Expand Up @@ -42,7 +51,7 @@ describe("Button without status — render unchanged", () => {
</Button>,
],
] as const)("%s", (key, el) => {
expect(renderToStaticMarkup(el)).toBe(BASELINE[key]);
expect(renderToStaticMarkup(el)).toBe(withPress(BASELINE[key]));
});

it("ignores status with asChild (Slot needs a single child)", () => {
Expand All @@ -51,7 +60,39 @@ describe("Button without status — render unchanged", () => {
<a href="/x">Ir</a>
</Button>
);
expect(html).toBe(BASELINE.asChild);
expect(html).toBe(withPress(BASELINE.asChild));
});
});

describe("Button loading shorthand", () => {
it("loading={true} is status=\"loading\"; reserves only the loading words", () => {
render(<Button loading>Enviar</Button>);
const btn = screen.getByRole("button");
expect(btn).toHaveAttribute("data-status", "loading");
expect(btn).toHaveAttribute("aria-busy", "true");
expect(btn).not.toHaveAttribute("loading");
expect(btn.querySelectorAll(".cf-btn__sizer")).toHaveLength(1);
});

it("loading={false} is status=\"idle\"; status wins over loading", () => {
const { rerender } = render(<Button loading={false}>Enviar</Button>);
expect(screen.getByRole("button")).toHaveAttribute("data-status", "idle");
rerender(
<Button loading status="success">
Enviar
</Button>
);
expect(screen.getByRole("button")).toHaveAttribute("data-status", "success");
expect(screen.getByRole("button").querySelectorAll(".cf-btn__sizer")).toHaveLength(3);
});

it("is ignored with asChild", () => {
const html = renderToStaticMarkup(
<Button asChild variant="link" loading>
<a href="/x">Ir</a>
</Button>
);
expect(html).toBe(withPress(BASELINE.asChild));
});
});

Expand Down
112 changes: 0 additions & 112 deletions src/__tests__/components/WorkspaceShell.test.tsx

This file was deleted.

4 changes: 2 additions & 2 deletions src/__tests__/lib/spring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ describe("springTransition", () => {
});

describe("CSS tokens", () => {
it("styles/index.css carries every preset (run npm run gen:springs if this fails)", () => {
const css = readFileSync(resolve(__dirname, "../../styles/index.css"), "utf8");
it("styles/motion.css carries every preset (run npm run gen:springs if this fails)", () => {
const css = readFileSync(resolve(__dirname, "../../styles/motion.css"), "utf8");
for (const [name, p] of Object.entries(SPRINGS)) {
const { easing, duration } = springToCSSLinear(p);
expect(css).toContain(`--cf-spring-${name}: ${easing};`);
Expand Down
Loading
Loading