Bootstrap @penpot/ui TypeScript component library with DS migrations

- Configure @penpot/ui package with full tooling: ESLint 9, Stylelint,
  Prettier, Vitest, Storybook (react-vite), vite-plugin-dts, React Compiler
- Add AGENTS.md with architecture overview, conventions, and migration table
- Fix Storybook react-docgen TypeScript parse error by removing empty .babelrc
  that disabled Babel's built-in typescript plugin
- Fix vite.config.mts for @vitejs/plugin-react v6 (reactCompilerPreset())
- Fix tsconfig.storybook.json (remove emitDecoratorMetadata without decorator)
- Add shared SCSS foundations: _borders, _sizes, _utils, typography
- Migrate Text, Heading (foundations/typography) with stories and tests
- Migrate Icon (foundations/assets) with full iconIds catalogue and stories
- Migrate Cta (product) with stories and tests
- Migrate Button (buttons) with polymorphic anchor/button rendering,
  4 variants, optional icon, and onRef callback; add _buttons.scss partial
- All checks passing: lint, check-fmt, typecheck, test (41 tests)
This commit is contained in:
Andrey Antukh 2026-04-07 20:36:30 +00:00
parent 10cfd99525
commit 42ea5def4f
39 changed files with 2503 additions and 30 deletions

View File

@ -1,4 +0,0 @@
{
"presets": [],
"plugins": []
}

View File

@ -0,0 +1,266 @@
# @penpot/ui Agent Instructions
TypeScript + React component library that forms the Penpot design system (DS).
Components are built in TypeScript/TSX, styled with CSS Modules (SCSS), tested
with Vitest + Testing Library, and documented with Storybook.
This package lives under `frontend/packages/ui/` and is published as the
`@penpot/ui` internal package consumed by the main `frontend/` ClojureScript
application.
## Architecture
```
frontend/packages/ui/
├── src/
│ ├── index.ts # Barrel all public exports
│ └── lib/
│ ├── _ds/ # Shared SCSS foundations (mixins, tokens)
│ │ ├── _borders.scss # Border-radius and border-width tokens
│ │ ├── _sizes.scss # Size tokens ($sz-*)
│ │ ├── _utils.scss # px2rem() helper
│ │ └── typography.scss # use-typography() mixin + font styles
│ ├── buttons/ # Button components
│ │ ├── _buttons.scss # Shared button placeholder/variant styles
│ │ ├── Button.tsx
│ │ ├── Button.module.scss
│ │ ├── Button.stories.tsx
│ │ └── Button.spec.tsx
│ ├── example/ # Example component (reference)
│ ├── foundations/
│ │ ├── assets/ # Icon component
│ │ │ ├── Icon.tsx
│ │ │ ├── Icon.module.scss
│ │ │ ├── Icon.stories.tsx
│ │ │ └── Icon.spec.tsx
│ │ └── typography/ # Text, Heading components + shared utilities
│ └── product/ # Product-level components (e.g. Cta)
├── eslint.config.mjs # ESLint 9 flat config (TypeScript + React)
├── stylelint.config.mjs # Stylelint config (mirrors frontend/)
├── vite.config.mts # Vite lib build + Vitest config
├── tsconfig.json
├── tsconfig.lib.json
├── tsconfig.spec.json
└── tsconfig.storybook.json
```
Components are organised to mirror the CLJS source tree
`frontend/src/app/main/ui/ds/`:
| CLJS path | TS path |
|-----------|---------|
| `ds/foundations/typography/text.cljs` | `src/lib/foundations/typography/Text.tsx` |
| `ds/foundations/typography/heading.cljs` | `src/lib/foundations/typography/Heading.tsx` |
| `ds/foundations/assets/icon.cljs` | `src/lib/foundations/assets/Icon.tsx` |
| `ds/product/cta.cljs` | `src/lib/product/Cta.tsx` |
| `ds/buttons/button.cljs` | `src/lib/buttons/Button.tsx` |
### Known Tooling Notes
- **No `.babelrc` in this package.** The `react-docgen` plugin used by
Storybook calls `@babel/core`'s `loadPartialConfig`. If a `.babelrc` is
present with empty `presets: []` it disables the default `typescript` Babel
plugin, causing `import type` to fail in story files. Keep the `.babelrc`
deleted.
- **`@vitejs/plugin-react` v6** removed the `babel` option. Use
`reactCompilerPreset()` from the same package instead of passing
`babel: { plugins: ['babel-plugin-react-compiler'] }`.
Every migrated component must have:
- `ComponentName.tsx` the React component
- `ComponentName.module.scss` CSS Module styles
- `ComponentName.stories.tsx` Storybook stories
- `ComponentName.spec.tsx` Vitest unit tests
## Development Commands
All commands must be run from `frontend/packages/ui/`.
```bash
# Build the library (outputs to dist/)
pnpm run build
# Watch mode (rebuilds on file changes)
pnpm run watch
# Type-check all tsconfig projects
pnpm run typecheck
# Run unit tests (Vitest, single pass)
pnpm run test
# Run tests in watch mode
pnpm run test:watch
# Launch Storybook dev server
pnpm run storybook
# Build Storybook static site
pnpm run build:storybook
# Lint TypeScript/TSX (ESLint)
pnpm run lint:ts
# Lint SCSS (Stylelint)
pnpm run lint:scss
# Lint all (TS/TSX + SCSS)
pnpm run lint
# Auto-fix formatting TS/TSX only
pnpm run fmt:ts
# Auto-fix formatting SCSS only
pnpm run fmt:scss
# Auto-fix formatting all (TS/TSX + SCSS)
pnpm run fmt
# Check formatting TS/TSX only
pnpm run check-fmt:ts
# Check formatting SCSS only
pnpm run check-fmt:scss
# Check formatting all (TS/TSX + SCSS)
pnpm run check-fmt
```
Always run the following checks after making changes and before committing:
```bash
pnpm run lint
pnpm run check-fmt
pnpm run typecheck
pnpm run test
```
## Component Conventions
### Naming
- PascalCase filenames: `MyComponent.tsx`, `MyComponent.module.scss`
- Named exports only — no default exports for components
- Export from `src/index.ts` (both the component and its props type)
### Component Structure
```tsx
import { type ComponentPropsWithRef, memo } from "react";
import clsx from "clsx";
import styles from "./MyComponent.module.scss";
export interface MyComponentProps extends ComponentPropsWithRef<"div"> {
/** Required prop description */
label: string;
}
function MyComponentInner({ label, className, children, ...rest }: MyComponentProps) {
return (
<div className={clsx(styles.root, className)} {...rest}>
<span>{label}</span>
{children}
</div>
);
}
export const MyComponent = memo(MyComponentInner);
```
Key rules:
- Wrap every component with `React.memo` (mirrors CLJS `mf/memo`)
- Use CSS Modules for all styles (`styles.className`, never inline styles)
- Use `clsx` to merge class names (mirrors CLJS `stl/css-case`)
- Spread `...rest` onto the root element to pass through HTML attributes
- Use `className` (not `class`) and merge it with the component's own class
### Props
- Accept an optional `className` prop and merge it with `clsx`
- For polymorphic components (variable tag), use an `as` prop typed as
`ElementType` and default to a sensible HTML element
- Validate variants/options with TypeScript union types, not runtime checks
### Styling
SCSS files live next to the component file and follow these rules:
- Import shared SCSS foundations with `@use`:
```scss
@use "../../_ds/typography.scss" as t;
@use "../../_ds/_utils.scss" as *;
```
- Use `px2rem()` for all hard-coded pixel values
- Use CSS custom properties for design tokens (`var(--color-*)`,
`var(--sp-*)`)
- Use `@include t.use-typography("headline-small")` for typography
- Use logical properties: `margin-inline-start`, `padding-block-end`, etc.
(not `margin-left`, `padding-bottom`)
- Flat selectors — avoid deep nesting
### Storybook Stories
```tsx
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MyComponent } from "./MyComponent";
const meta = {
title: "Category/MyComponent", // mirrors CLJS story category
component: MyComponent,
args: { label: "Default label" },
} satisfies Meta<typeof MyComponent>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
```
### Unit Tests
```tsx
import { render } from "@testing-library/react";
import { MyComponent } from "./MyComponent";
describe("MyComponent", () => {
it("should render successfully", () => {
const { baseElement } = render(<MyComponent label="test" />);
expect(baseElement).toBeTruthy();
});
});
```
- Use `@testing-library/react` — test rendered output, not implementation
- No snapshot tests
- Cover: renders correctly, prop variations, className merging, HTML
attribute pass-through
- **SVG `className` is an `SVGAnimatedString` in JSDOM** — use
`svg.getAttribute("class")` instead of `svg.className` in tests
- **`toHaveAttribute` is not available** (no `@testing-library/jest-dom` setup) —
use `element.getAttribute("attr")` directly
## Migration from CLJS DS
When migrating a component from `frontend/src/app/main/ui/ds/`:
| CLJS pattern | TypeScript equivalent |
|--------------|-----------------------|
| `mf/defc cta* {::mf/wrap [mf/memo]}` | `export const Cta = memo(CtaInner)` |
| `(stl/css :root)` | `styles.root` (CSS Module) |
| `(stl/css-case :a cond :b true)` | `clsx({ [styles.a]: cond, [styles.b]: true })` |
| `(d/append-class cls (stl/css :root))` | `clsx(styles.root, className)` |
| `[:> text* {:as "span" :typography t/headline-small}]` | `<Text as="span" typography="headline-small" />` |
| `{:keys [class title children] :rest props}` | `{ className, title, children, ...rest }` |
| `[:> "div" props ...]` | `<div className={...} {...rest}>...</div>` |
CLJS schema validation (`:map [:title :string]`) is replaced by TypeScript
`interface` / prop types — no runtime validation needed.
## Exports
Every public symbol must be re-exported from `src/index.ts`:
```ts
export { MyComponent } from './lib/category/MyComponent';
export type { MyComponentProps } from './lib/category/MyComponent';
```

View File

@ -0,0 +1,100 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import tseslint from "typescript-eslint";
import pluginReact from "eslint-plugin-react";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginJsxA11y from "eslint-plugin-jsx-a11y";
import pluginImport from "eslint-plugin-import";
import globals from "globals";
/** @type {import("eslint").Linter.Config[]} */
export default [
{
ignores: ["dist/**", "node_modules/**", ".storybook/**"],
},
// TypeScript + TSX source files
...tseslint.config({
files: ["src/**/*.{ts,tsx}"],
extends: [
tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.es2022,
},
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
plugins: {
"react-hooks": pluginReactHooks,
"jsx-a11y": pluginJsxA11y,
import: pluginImport,
},
settings: {
react: { version: "detect" },
},
rules: {
// React
"react/react-in-jsx-scope": "off", // Not needed with React 17+ JSX transform
"react/prop-types": "off", // TypeScript handles prop validation
"react/display-name": "off", // memo-wrapped inner functions don't need display names
// React Hooks
...pluginReactHooks.configs["recommended-latest"].rules,
// Imports
"import/no-duplicates": "error",
"import/no-unresolved": "off", // TypeScript handles resolution
"import/first": "error",
// TypeScript — relax rules that conflict with common patterns
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
// General
"no-console": ["warn", { allow: ["warn", "error"] }],
"prefer-const": "error",
"no-var": "error",
},
}),
// Vitest spec files — add test globals
...tseslint.config({
files: ["src/**/*.spec.{ts,tsx}"],
languageOptions: {
globals: {
...globals.browser,
...globals.es2022,
describe: "readonly",
it: "readonly",
expect: "readonly",
beforeEach: "readonly",
afterEach: "readonly",
beforeAll: "readonly",
afterAll: "readonly",
vi: "readonly",
},
},
}),
// Storybook story files — relax some rules
...tseslint.config({
files: ["src/**/*.stories.{ts,tsx}"],
rules: {
"import/first": "off",
"@typescript-eslint/no-unused-vars": "off",
},
}),
];

View File

@ -10,8 +10,22 @@
"./style.css": "./dist/style.css"
},
"scripts": {
"build": "vite build",
"watch": "vite build --watch",
"build": "vite build"
"typecheck": "tsc --build",
"test": "vitest run",
"test:watch": "vitest",
"storybook": "storybook dev -p 6006",
"build:storybook": "storybook build",
"lint:ts": "eslint src",
"lint:scss": "stylelint 'src/**/*.scss'",
"lint": "pnpm run lint:ts && pnpm run lint:scss",
"fmt:ts": "prettier -w 'src/**/*.{ts,tsx}'",
"fmt:scss": "prettier -w 'src/**/*.scss'",
"fmt": "pnpm run fmt:ts && pnpm run fmt:scss",
"check-fmt:ts": "prettier -c 'src/**/*.{ts,tsx}'",
"check-fmt:scss": "prettier -c 'src/**/*.scss'",
"check-fmt": "pnpm run check-fmt:ts && pnpm run check-fmt:scss"
},
"devDependencies": {
"@babel/core": "^7.14.5",
@ -24,13 +38,25 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"clsx": "^2.1.1",
"eslint": "^9.39.2",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"postcss-scss": "^4.0.9",
"prettier": "3.8.1",
"react-compiler-runtime": "^1.0.0",
"sass": "^1.98.0",
"storybook": "10.3.5",
"vite-plugin-dts": "^4.5.4"
"stylelint": "^17.4.0",
"stylelint-config-standard-scss": "^17.0.0",
"stylelint-scss": "^7.0.0",
"stylelint-use-logical-spec": "^5.0.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.0",
"vite-plugin-dts": "^4.5.4",
"vitest": "^4.1.3"
},
"peerDependencies": {
"react": ">=19.2",

View File

@ -1 +1,17 @@
export * from './lib/example/Example';
export * from "./lib/example/Example";
export { Text } from "./lib/foundations/typography/Text";
export type { TextProps } from "./lib/foundations/typography/Text";
export { Heading } from "./lib/foundations/typography/Heading";
export type { HeadingProps } from "./lib/foundations/typography/Heading";
export {
Typography,
typographyIds,
typographySet,
} from "./lib/foundations/typography/typography";
export type { TypographyId } from "./lib/foundations/typography/typography";
export { Cta } from "./lib/product/Cta";
export type { CtaProps } from "./lib/product/Cta";
export { Icon, iconIds } from "./lib/foundations/assets/Icon";
export type { IconId, IconProps } from "./lib/foundations/assets/Icon";
export { Button } from "./lib/buttons/Button";
export type { ButtonProps, ButtonVariant } from "./lib/buttons/Button";

View File

@ -0,0 +1,15 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "utils" as *;
$br-4: px2rem(4);
$br-6: px2rem(6);
$br-8: px2rem(8);
$br-12: px2rem(12);
$br-circle: 50%;
$b-1: px2rem(1);
$b-2: px2rem(2);

View File

@ -0,0 +1,39 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "utils" as *;
$sz-1: px2rem(1);
$sz-6: px2rem(6);
$sz-12: px2rem(12);
$sz-14: px2rem(14);
$sz-16: px2rem(16);
$sz-24: px2rem(24);
$sz-28: px2rem(28);
$sz-32: px2rem(32);
$sz-36: px2rem(36);
$sz-40: px2rem(40);
$sz-48: px2rem(48);
$sz-64: px2rem(64);
$sz-88: px2rem(88);
$sz-96: px2rem(96);
$sz-120: px2rem(120);
$sz-154: px2rem(154);
$sz-160: px2rem(160);
$sz-192: px2rem(192);
$sz-200: px2rem(200);
$sz-224: px2rem(224);
$sz-252: px2rem(252);
$sz-284: px2rem(284);
$sz-352: px2rem(352);
$sz-364: px2rem(364);
$sz-384: px2rem(384);
$sz-400: px2rem(400);
$sz-430: px2rem(430);
$sz-480: px2rem(480);
$sz-500: px2rem(500);
$sz-512: px2rem(512);
$sz-520: px2rem(520);

View File

@ -0,0 +1,13 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "sass:math";
@function px2rem($value) {
$rem-value: math.div($value, 16) * 1rem;
@return $rem-value;
}

View File

@ -0,0 +1,139 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "utils" as *;
$_font-weight-regular: 400;
$_font-weight-medium: 500;
$_font-lineheight-dense: 1.2;
$_font-lineheight-compact: 1.3;
$_font-lineheight-normal: 1.4;
$_fs-12: px2rem(12);
$_fs-14: px2rem(14);
$_fs-16: px2rem(16);
$_fs-18: px2rem(18);
$_fs-20: px2rem(20);
$_fs-24: px2rem(24);
$_fs-36: px2rem(36);
@mixin _font-style-display {
font-family: worksans, vazirmatn, sans-serif;
font-optical-sizing: auto;
font-weight: $_font-weight-regular;
line-height: $_font-lineheight-normal;
font-size: $_fs-36;
}
@mixin _font-style-title-large {
font-family: worksans, vazirmatn, sans-serif;
font-optical-sizing: auto;
font-weight: $_font-weight-regular;
line-height: $_font-lineheight-normal;
font-size: $_fs-24;
}
@mixin _font-style-title-medium {
font-family: worksans, vazirmatn, sans-serif;
font-optical-sizing: auto;
font-weight: $_font-weight-regular;
line-height: $_font-lineheight-normal;
font-size: $_fs-20;
}
@mixin _font-style-title-small {
font-family: worksans, vazirmatn, sans-serif;
font-optical-sizing: auto;
font-weight: $_font-weight-regular;
line-height: $_font-lineheight-dense;
font-size: $_fs-14;
}
@mixin _font-style-headline-large {
font-family: worksans, vazirmatn, sans-serif;
font-optical-sizing: auto;
font-weight: $_font-weight-regular;
line-height: $_font-lineheight-normal;
font-size: $_fs-18;
text-transform: uppercase;
}
@mixin _font-style-headline-medium {
font-family: worksans, vazirmatn, sans-serif;
font-optical-sizing: auto;
font-weight: $_font-weight-regular;
line-height: $_font-lineheight-normal;
font-size: $_fs-16;
text-transform: uppercase;
}
@mixin _font-style-headline-small {
font-family: worksans, vazirmatn, sans-serif;
font-optical-sizing: auto;
font-weight: $_font-weight-medium;
line-height: $_font-lineheight-dense;
font-size: $_fs-12;
text-transform: uppercase;
}
@mixin _font-style-body-large {
font-family: worksans, vazirmatn, sans-serif;
font-optical-sizing: auto;
font-weight: $_font-weight-regular;
line-height: $_font-lineheight-normal;
font-size: $_fs-16;
}
@mixin _font-style-body-medium {
font-family: worksans, vazirmatn, sans-serif;
font-optical-sizing: auto;
font-weight: $_font-weight-regular;
line-height: $_font-lineheight-normal;
font-size: $_fs-14;
}
@mixin _font-style-body-small {
font-family: worksans, vazirmatn, sans-serif;
font-optical-sizing: auto;
font-weight: $_font-weight-regular;
line-height: $_font-lineheight-compact;
font-size: $_fs-12;
}
@mixin _font-style-code-font {
font-family: "Roboto Mono", monospace;
font-optical-sizing: auto;
font-weight: $_font-weight-regular;
line-height: $_font-lineheight-dense;
font-size: $_fs-12;
}
@mixin use-typography($typography-name) {
@if $typography-name == "display" {
@include _font-style-display;
} @else if $typography-name == "title-large" {
@include _font-style-title-large;
} @else if $typography-name == "title-medium" {
@include _font-style-title-medium;
} @else if $typography-name == "title-small" {
@include _font-style-title-small;
} @else if $typography-name == "headline-large" {
@include _font-style-headline-large;
} @else if $typography-name == "headline-medium" {
@include _font-style-headline-medium;
} @else if $typography-name == "headline-small" {
@include _font-style-headline-small;
} @else if $typography-name == "body-large" {
@include _font-style-body-large;
} @else if $typography-name == "body-medium" {
@include _font-style-body-medium;
} @else if $typography-name == "body-small" {
@include _font-style-body-small;
} @else if $typography-name == "code-font" {
@include _font-style-code-font;
} @else {
@error "Unknown typography: " + $typography-name;
}
}

View File

@ -0,0 +1,44 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "../_ds/typography.scss" as t;
@use "buttons" as *;
.button {
@extend %base-button;
@include t.use-typography("headline-small");
padding: 0 var(--sp-m);
display: inline-flex;
align-items: center;
column-gap: var(--sp-xs);
}
.button-primary {
@extend %base-button-primary;
}
.button-secondary {
@extend %base-button-secondary;
}
.button-ghost {
@extend %base-button-ghost;
}
.button-destructive {
@extend %base-button-destructive;
}
.button-link {
&:hover {
text-decoration: none;
}
}
.label-wrapper {
display: contents;
}

View File

@ -0,0 +1,80 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import { render, screen } from "@testing-library/react";
import { Button } from "./Button";
describe("Button", () => {
it("should render successfully", () => {
const { baseElement } = render(<Button>Click me</Button>);
expect(baseElement).toBeTruthy();
});
it("renders as a <button> by default", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeTruthy();
});
it("renders as an <a> when `to` is provided", () => {
render(<Button to="https://example.com">Go</Button>);
const link = screen.getByRole("link", { name: "Go" });
expect(link.tagName).toBe("A");
expect(link.getAttribute("href")).toBe("https://example.com");
});
it("applies primary variant class by default", () => {
const { container } = render(<Button>Primary</Button>);
const btn = container.querySelector("button");
expect(btn?.className).toContain("button-primary");
});
it("applies secondary variant class", () => {
const { container } = render(<Button variant="secondary">Sec</Button>);
expect(container.querySelector("button")?.className).toContain(
"button-secondary",
);
});
it("applies ghost variant class", () => {
const { container } = render(<Button variant="ghost">Ghost</Button>);
expect(container.querySelector("button")?.className).toContain(
"button-ghost",
);
});
it("applies destructive variant class", () => {
const { container } = render(<Button variant="destructive">Danger</Button>);
expect(container.querySelector("button")?.className).toContain(
"button-destructive",
);
});
it("renders an Icon when `icon` is provided", () => {
const { container } = render(<Button icon="pin">With icon</Button>);
expect(container.querySelector("svg")).not.toBeNull();
});
it("does not render an Icon when `icon` is not provided", () => {
const { container } = render(<Button>No icon</Button>);
expect(container.querySelector("svg")).toBeNull();
});
it("merges custom className", () => {
const { container } = render(<Button className="custom">Click me</Button>);
expect(container.querySelector("button")?.className).toContain("custom");
});
it("calls onRef with the DOM node", () => {
const onRef = vi.fn();
render(<Button onRef={onRef}>Ref</Button>);
expect(onRef).toHaveBeenCalledWith(expect.any(HTMLButtonElement));
});
it("forwards arbitrary props to the root element", () => {
render(<Button data-testid="my-btn">Click</Button>);
expect(screen.getByTestId("my-btn")).toBeTruthy();
});
});

View File

@ -0,0 +1,57 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import type { Meta, StoryObj } from "@storybook/react-vite";
import { iconIds } from "../foundations/assets/Icon";
import { Button } from "./Button";
const meta = {
title: "Buttons/Button",
component: Button,
args: {
children: "Lorem ipsum",
variant: "primary",
},
argTypes: {
icon: {
options: [undefined, ...iconIds],
control: { type: "select" },
},
variant: {
options: ["primary", "secondary", "ghost", "destructive"],
control: { type: "select" },
},
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Primary: Story = {
args: { variant: "primary" },
};
export const Secondary: Story = {
args: { variant: "secondary" },
};
export const Ghost: Story = {
args: { variant: "ghost" },
};
export const Destructive: Story = {
args: { variant: "destructive" },
};
export const WithIcon: Story = {
args: { icon: "effects" },
};
export const AsLink: Story = {
args: { to: "https://penpot.app" },
};

View File

@ -0,0 +1,118 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import { type ComponentPropsWithRef, memo } from "react";
import clsx from "clsx";
import { Icon, type IconId } from "../foundations/assets/Icon";
import styles from "./Button.module.scss";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ButtonVariant = "primary" | "secondary" | "ghost" | "destructive";
interface ButtonBaseProps {
/** Visual variant. Defaults to "primary". */
variant?: ButtonVariant;
/** Optional icon ID rendered before the label. */
icon?: IconId;
/** Callback that receives the underlying DOM element ref. */
onRef?: (node: HTMLElement | null) => void;
className?: string;
children?: React.ReactNode;
}
// Conditional props: when `to` is provided the component renders as <a>;
// otherwise as <button>.
type ButtonAsButton = ButtonBaseProps &
Omit<ComponentPropsWithRef<"button">, keyof ButtonBaseProps> & {
/** When set, renders as an anchor element pointing to this URL. */
to?: undefined;
};
type ButtonAsAnchor = ButtonBaseProps &
Omit<ComponentPropsWithRef<"a">, keyof ButtonBaseProps> & {
/** When set, renders as an anchor element pointing to this URL. */
to: string;
};
export type ButtonProps = ButtonAsButton | ButtonAsAnchor;
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function getComputedClass(
variant: ButtonVariant,
isLink: boolean,
className?: string,
) {
return clsx(
styles.button,
{
[styles["button-link"]]: isLink,
[styles["button-primary"]]: variant === "primary",
[styles["button-secondary"]]: variant === "secondary",
[styles["button-ghost"]]: variant === "ghost",
[styles["button-destructive"]]: variant === "destructive",
},
className,
);
}
function ButtonContent({
icon,
children,
}: {
icon?: IconId;
children?: React.ReactNode;
}) {
return (
<>
{icon && <Icon iconId={icon} size="m" />}
<span className={styles["label-wrapper"]}>{children}</span>
</>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function ButtonInner(props: ButtonProps) {
const { variant = "primary", icon, onRef, className, children } = props;
if (props.to !== undefined) {
// Anchor branch
const { to, variant: _v, icon: _i, onRef: _r, ...rest } = props;
return (
<a
href={to}
className={getComputedClass(variant, true, className)}
ref={(node) => onRef?.(node)}
{...rest}
>
<ButtonContent icon={icon}>{children}</ButtonContent>
</a>
);
}
// Button branch
const { variant: _v, icon: _i, onRef: _r, to: _t, ...rest } = props;
return (
<button
type="button"
className={getComputedClass(variant, false, className)}
ref={(node) => onRef?.(node)}
{...rest}
>
<ButtonContent icon={icon}>{children}</ButtonContent>
</button>
);
}
export const Button = memo(ButtonInner);

View File

@ -0,0 +1,127 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "../_ds/_borders.scss" as *;
@use "../_ds/_sizes.scss" as *;
@use "../_ds/_utils.scss" as *;
%base-button {
--button-bg-color: initial;
--button-fg-color: initial;
--button-hover-bg-color: initial;
--button-hover-fg-color: initial;
--button-active-bg-color: initial;
--button-active-fg-color: initial;
--button-disabled-bg-color: initial;
--button-disabled-fg-color: initial;
--button-border-color: var(--button-bg-color);
--button-focus-inner-ring-color: initial;
--button-focus-outer-ring-color: initial;
--button-width: initial;
--button-height: #{$sz-32};
appearance: none;
width: var(--button-width);
height: var(--button-height);
background: var(--button-bg-color);
color: var(--button-fg-color);
border: $b-1 solid var(--button-border-color);
border-radius: $br-8;
&:hover {
--button-bg-color: var(--button-hover-bg-color);
--button-fg-color: var(--button-hover-fg-color);
}
&:active,
&[aria-pressed="true"] {
--button-bg-color: var(--button-active-bg-color);
--button-fg-color: var(--button-active-fg-color);
}
&:focus-visible {
outline: var(--button-focus-inner-ring-color) solid #{px2rem(2)};
outline-offset: -#{px2rem(3)};
--button-border-color: var(--button-focus-outer-ring-color);
--button-fg-color: var(--button-focus-fg-color);
}
&:disabled {
--button-bg-color: var(--button-disabled-bg-color);
--button-fg-color: var(--button-disabled-fg-color);
}
}
%base-button-primary {
--button-bg-color: var(--color-accent-primary);
--button-fg-color: var(--color-background-secondary);
--button-hover-bg-color: var(--color-accent-tertiary);
--button-hover-fg-color: var(--color-background-secondary);
--button-active-bg-color: var(--color-accent-tertiary);
--button-active-fg-color: var(--color-background-secondary);
--button-disabled-bg-color: var(--color-accent-primary-muted);
--button-disabled-fg-color: var(--color-background-secondary);
--button-focus-bg-color: var(--color-accent-primary);
--button-focus-fg-color: var(--color-background-secondary);
--button-focus-inner-ring-color: var(--color-background-secondary);
--button-focus-outer-ring-color: var(--color-accent-primary);
&:active,
&[aria-pressed="true"] {
box-shadow: inset 0 0 #{px2rem(10)} #{px2rem(2)} rgb(0 0 0 / 20%);
}
}
%base-button-secondary {
--button-bg-color: var(--color-background-tertiary);
--button-fg-color: var(--color-foreground-secondary);
--button-hover-bg-color: var(--color-background-tertiary);
--button-hover-fg-color: var(--color-accent-primary);
--button-active-bg-color: var(--color-background-quaternary);
--button-active-fg-color: var(--color-accent-primary);
--button-disabled-bg-color: transparent;
--button-disabled-fg-color: var(--color-foreground-secondary);
--button-focus-bg-color: var(--color-background-tertiary);
--button-focus-fg-color: var(--color-foreground-primary);
--button-focus-inner-ring-color: var(--color-background-secondary);
--button-focus-outer-ring-color: var(--color-accent-primary);
}
%base-button-ghost {
--button-bg-color: transparent;
--button-fg-color: var(--color-foreground-secondary);
--button-hover-bg-color: var(--color-background-tertiary);
--button-hover-fg-color: var(--color-accent-primary);
--button-active-bg-color: var(--color-background-quaternary);
--button-active-fg-color: var(--color-accent-primary);
--button-disabled-bg-color: transparent;
--button-disabled-fg-color: var(--color-accent-primary-muted);
--button-focus-bg-color: transparent;
--button-focus-fg-color: var(--color-foreground-secondary);
--button-focus-inner-ring-color: transparent;
--button-focus-outer-ring-color: var(--color-accent-primary);
}
%base-button-destructive {
--button-bg-color: var(--color-accent-error);
--button-fg-color: var(--color-foreground-primary);
--button-hover-bg-color: var(--color-background-error);
--button-hover-fg-color: var(--color-foreground-primary);
--button-active-bg-color: var(--color-accent-error);
--button-active-fg-color: var(--color-foreground-primary);
--button-disabled-bg-color: var(--color-background-error);
--button-disabled-fg-color: var(--color-accent-error);
--button-focus-bg-color: var(--color-accent-error);
--button-focus-fg-color: var(--color-foreground-primary);
--button-focus-inner-ring-color: var(--color-background-primary);
--button-focus-outer-ring-color: var(--color-accent-primary);
&:active,
&[aria-pressed="true"] {
box-shadow: inset 0 0 #{px2rem(10)} #{px2rem(2)} rgb(0 0 0 / 20%);
}
}

View File

@ -1,9 +1,9 @@
import { render } from '@testing-library/react';
import { render } from "@testing-library/react";
import Example from './Example';
import Example from "./Example";
describe('Example', () => {
it('should render successfully', () => {
describe("Example", () => {
it("should render successfully", () => {
const { baseElement } = render(<Example />);
expect(baseElement).toBeTruthy();
});

View File

@ -1,8 +1,8 @@
import { Example } from './Example';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Example } from "./Example";
import type { Meta, StoryObj } from "@storybook/react-vite";
const meta = {
title: 'UI/Example',
title: "UI/Example",
component: Example,
} satisfies Meta<typeof Example>;

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import styles from './Example.module.css';
import { useState } from "react";
import styles from "./Example.module.css";
export function Example() {
const [count, setCount] = useState(0);
@ -14,7 +14,6 @@ export function Example() {
<button onClick={() => setCount(0)}>Reset</button>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
.icon {
fill: var(--icon-fill-color, none);
stroke: var(--icon-stroke-color, currentcolor);
}

View File

@ -0,0 +1,70 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import { render } from "@testing-library/react";
import { Icon, iconIds } from "./Icon";
describe("Icon", () => {
it("should render successfully", () => {
const { baseElement } = render(<Icon iconId="pin" />);
expect(baseElement).toBeTruthy();
});
it("renders an svg element", () => {
const { container } = render(<Icon iconId="pin" />);
expect(container.querySelector("svg")).not.toBeNull();
});
it("references the correct icon sprite href", () => {
const { container } = render(<Icon iconId="add" />);
const use = container.querySelector("use");
expect(use?.getAttribute("href")).toBe("#icon-add");
});
it("defaults to medium size (16px viewport)", () => {
const { container } = render(<Icon iconId="pin" />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("width")).toBe("16");
expect(svg?.getAttribute("height")).toBe("16");
});
it("renders large size with 32px viewport", () => {
const { container } = render(<Icon iconId="pin" size="l" />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("width")).toBe("32");
expect(svg?.getAttribute("height")).toBe("32");
});
it("renders small size with 16px viewport and offset use", () => {
const { container } = render(<Icon iconId="pin" size="s" />);
const use = container.querySelector("use");
expect(use?.getAttribute("width")).toBe("12");
// offset = (16 - 12) / 2 = 2
expect(use?.getAttribute("x")).toBe("2");
});
it("forwards className to the svg element", () => {
const { container } = render(
<Icon iconId="pin" className="custom-class" />,
);
expect(container.querySelector("svg")?.getAttribute("class")).toContain(
"custom-class",
);
});
it("forwards arbitrary svg props", () => {
const { container } = render(
<Icon iconId="pin" data-testid="my-icon" aria-label="pin icon" />,
);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("data-testid")).toBe("my-icon");
expect(svg?.getAttribute("aria-label")).toBe("pin icon");
});
it("exports a non-empty iconIds array", () => {
expect(iconIds.length).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,40 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Icon, iconIds } from "./Icon";
const meta = {
title: "Foundations/Assets/Icon",
component: Icon,
args: {
iconId: "pin",
size: "m",
},
argTypes: {
iconId: {
options: iconIds,
control: { type: "select" },
},
size: {
options: ["s", "m", "l"],
control: { type: "radio" },
},
},
} satisfies Meta<typeof Icon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Small: Story = {
args: { size: "s" },
};
export const Large: Story = {
args: { size: "l" },
};

View File

@ -0,0 +1,359 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import { type ComponentPropsWithRef, memo } from "react";
import clsx from "clsx";
import styles from "./Icon.module.scss";
// ---------------------------------------------------------------------------
// Icon ID catalogue
// ---------------------------------------------------------------------------
export const iconIds = [
"absolute",
"add",
"align-bottom",
"align-content-column-around",
"align-content-column-between",
"align-content-column-center",
"align-content-column-end",
"align-content-column-evenly",
"align-content-column-start",
"align-content-column-stretch",
"align-content-row-around",
"align-content-row-between",
"align-content-row-center",
"align-content-row-end",
"align-content-row-evenly",
"align-content-row-start",
"align-content-row-stretch",
"align-horizontal-center",
"align-items-column-center",
"align-items-column-end",
"align-items-column-start",
"align-items-row-center",
"align-items-row-end",
"align-items-row-start",
"align-left",
"align-right",
"align-self-column-bottom",
"align-self-column-center",
"align-self-column-stretch",
"align-self-column-top",
"align-self-row-center",
"align-self-row-left",
"align-self-row-right",
"align-self-row-stretch",
"align-top",
"align-vertical-center",
"arrow",
"arrow-down",
"arrow-left",
"arrow-right",
"arrow-up",
"arrow-up-right",
"asc-sort",
"at",
"board",
"boards-thumbnail",
"boolean-difference",
"boolean-exclude",
"boolean-flatten",
"boolean-intersection",
"boolean-union",
"broken-link",
"bug",
"character-a",
"character-b",
"character-c",
"character-d",
"character-e",
"character-f",
"character-g",
"character-h",
"character-i",
"character-j",
"character-k",
"character-l",
"character-m",
"character-n",
"character-ntilde",
"character-o",
"character-p",
"character-q",
"character-r",
"character-s",
"character-t",
"character-u",
"character-v",
"character-w",
"character-x",
"character-y",
"character-z",
"clip-content",
"clipboard",
"clock",
"close",
"close-small",
"code",
"column",
"column-reverse",
"comments",
"component",
"component-copy",
"constraint-horizontal",
"constraint-vertical",
"corner-bottom",
"corner-bottom-left",
"corner-bottom-right",
"corner-center",
"corner-radius",
"corner-top",
"corner-top-left",
"corner-top-right",
"crown",
"curve",
"delete",
"delete-text",
"desc-sort",
"detach",
"detached",
"distribute-horizontally",
"distribute-vertical-spacing",
"document",
"download",
"drop",
"drop-shadow",
"easing-ease",
"easing-ease-in",
"easing-ease-in-out",
"easing-ease-out",
"easing-linear",
"effects",
"elipse",
"exit",
"expand",
"external-link",
"eye-off",
"feedback",
"fill-content",
"filter",
"fixed-width",
"fit-content",
"flex",
"flex-grid",
"flex-horizontal",
"flex-vertical",
"flip-horizontal",
"flip-vertical",
"folder",
"gap-horizontal",
"gap-vertical",
"graphics",
"grid",
"grid-column",
"grid-columns",
"grid-gutter",
"grid-margin",
"grid-row",
"grid-rows",
"grid-square",
"group",
"gutter-horizontal",
"gutter-vertical",
"help",
"hide",
"history",
"hsva",
"hug-content",
"icon",
"img",
"inner-shadow",
"info",
"import-export",
"interaction",
"join-nodes",
"justify-content-column-around",
"justify-content-column-between",
"justify-content-column-center",
"justify-content-column-end",
"justify-content-column-evenly",
"justify-content-column-start",
"justify-content-row-around",
"justify-content-row-between",
"justify-content-row-center",
"justify-content-row-end",
"justify-content-row-evenly",
"justify-content-row-start",
"layers",
"library",
"locate",
"lock",
"margin",
"margin-bottom",
"margin-left",
"margin-left-right",
"margin-right",
"margin-top",
"margin-top-bottom",
"mask",
"masked",
"menu",
"merge-nodes",
"move",
"msg-error",
"msg-neutral",
"msg-success",
"msg-warning",
"number",
"open-link",
"padding-bottom",
"padding-extended",
"padding-left",
"padding-left-right",
"padding-right",
"padding-top",
"padding-top-bottom",
"path",
"pentool",
"percentage",
"picker",
"pin",
"play",
"puzzle",
"rectangle",
"reload",
"remove",
"reorder",
"rgba",
"rgba-complementary",
"rotation",
"row",
"row-reverse",
"search",
"separate-nodes",
"settings",
"shown",
"size-horizontal",
"size-vertical",
"snap-nodes",
"status-alert",
"status-tick",
"status-update",
"status-wrong",
"stroke-arrow",
"stroke-center",
"stroke-circle",
"stroke-dashed",
"stroke-diamond",
"stroke-dotted",
"stroke-inside",
"stroke-mixed",
"stroke-outside",
"stroke-rectangle",
"stroke-rounded",
"stroke-size",
"stroke-squared",
"stroke-solid",
"stroke-triangle",
"svg",
"swatches",
"switch",
"text",
"text-align-center",
"text-align-left",
"text-align-right",
"text-auto-height",
"text-auto-width",
"text-bottom",
"text-fixed",
"text-font-family",
"text-font-size",
"text-font-weight",
"text-justify",
"text-letterspacing",
"text-lineheight",
"text-lowercase",
"text-ltr",
"text-middle",
"text-mixed",
"text-palette",
"text-paragraph",
"text-rtl",
"text-stroked",
"text-top",
"text-typography",
"text-underlined",
"text-uppercase",
"thumbnail",
"tick",
"tokens",
"to-corner",
"to-curve",
"tree",
"unlock",
"user",
"variant",
"vertical-align-items-center",
"vertical-align-items-end",
"vertical-align-items-start",
"view-as-icons",
"view-as-list",
"wrap",
] as const;
export type IconId = (typeof iconIds)[number];
// ---------------------------------------------------------------------------
// Icon size constants
// ---------------------------------------------------------------------------
const ICON_SIZE_L = 32;
const ICON_SIZE_M = 16;
const ICON_SIZE_S = 12;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export interface IconProps extends Omit<
ComponentPropsWithRef<"svg">,
"children"
> {
/** Icon identifier — must be one of the registered icon IDs */
iconId: IconId;
/** Visual size variant. Defaults to "m" (16 px). */
size?: "s" | "m" | "l";
}
function IconInner({ iconId, size = "m", className, ...rest }: IconProps) {
const sizePx =
size === "l" ? ICON_SIZE_L : size === "s" ? ICON_SIZE_S : ICON_SIZE_M;
// For "s" and "m" the SVG viewport is always 16 px; the <use> is offset to
// centre the smaller glyph inside it.
const viewportSize = size === "l" ? ICON_SIZE_L : ICON_SIZE_M;
const offset = size === "s" || size === "m" ? (ICON_SIZE_M - sizePx) / 2 : 0;
return (
<svg
className={clsx(styles.icon, className)}
width={viewportSize}
height={viewportSize}
{...rest}
>
<use
href={`#icon-${iconId}`}
width={sizePx}
height={sizePx}
x={offset}
y={offset}
/>
</svg>
);
}
export const Icon = memo(IconInner);

View File

@ -0,0 +1,82 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC
*/
import { render } from "@testing-library/react";
import { Heading } from "./Heading";
describe("Heading", () => {
it("should render successfully", () => {
const { baseElement } = render(
<Heading typography="display">Hello</Heading>,
);
expect(baseElement).toBeTruthy();
});
it("should render as h1 by default", () => {
const { container } = render(<Heading typography="display">Hello</Heading>);
const element = container.querySelector("h1");
expect(element).toBeTruthy();
expect(element?.textContent).toBe("Hello");
});
it("should render the correct heading level", () => {
const { container } = render(
<Heading level={3} typography="title-medium">
Level 3
</Heading>,
);
const element = container.querySelector("h3");
expect(element).toBeTruthy();
expect(element?.textContent).toBe("Level 3");
});
it("should apply typography class", () => {
const { container } = render(
<Heading typography="title-large">Styled</Heading>,
);
const element = container.querySelector("h1");
expect(element?.className).toContain("title-large-typography");
});
it("should merge custom className with typography class", () => {
const { container } = render(
<Heading typography="display" className="custom-class">
Merged
</Heading>,
);
const element = container.querySelector("h1");
expect(element?.className).toContain("display-typography");
expect(element?.className).toContain("custom-class");
});
it("should pass through additional HTML attributes", () => {
const { container } = render(
<Heading typography="body-small" data-testid="my-heading" id="heading-1">
Attrs
</Heading>,
);
const element = container.querySelector("h1");
expect(element?.getAttribute("data-testid")).toBe("my-heading");
expect(element?.getAttribute("id")).toBe("heading-1");
});
it("should render all heading levels (1-6)", () => {
const levels = [1, 2, 3, 4, 5, 6] as const;
for (const level of levels) {
const { container } = render(
<Heading level={level} typography="body-medium">
H{level}
</Heading>,
);
const element = container.querySelector(`h${level}`);
expect(element).toBeTruthy();
expect(element?.textContent).toBe(`H${level}`);
}
});
});

View File

@ -0,0 +1,84 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Heading } from "./Heading";
import { typographyIds } from "./typography";
const meta = {
title: "Foundations/Typography/Heading",
component: Heading,
argTypes: {
level: {
options: [1, 2, 3, 4, 5, 6],
control: { type: "select" },
},
typography: {
options: typographyIds,
control: { type: "select" },
},
},
parameters: {
controls: { exclude: ["children"] },
},
args: {
children: "Lorem ipsum",
},
render: ({ children, ...args }) => <Heading {...args}>{children}</Heading>,
} satisfies Meta<typeof Heading>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
level: 1,
typography: "display",
},
};
export const Level2: Story = {
args: {
level: 2,
typography: "title-large",
children: "Title Large h2",
},
};
export const Level3: Story = {
args: {
level: 3,
typography: "title-medium",
children: "Title Medium h3",
},
};
export const Level4: Story = {
args: {
level: 4,
typography: "headline-large",
children: "Headline Large h4",
},
};
export const Level5: Story = {
args: {
level: 5,
typography: "headline-medium",
children: "Headline Medium h5",
},
};
export const Level6: Story = {
args: {
level: 6,
typography: "headline-small",
children: "Headline Small h6",
},
};

View File

@ -0,0 +1,40 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC
*/
import { type ComponentPropsWithRef, memo } from "react";
import clsx from "clsx";
import type { TypographyId } from "./typography";
import { typographyClassMap } from "./typographyClassMap";
type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
type HeadingTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
export interface HeadingProps extends Omit<
ComponentPropsWithRef<"h1">,
"level"
> {
/** The heading level (16). Defaults to `1`. */
level?: HeadingLevel;
/** The typography style to apply. */
typography: TypographyId;
}
function HeadingInner(props: HeadingProps) {
const { level = 1, typography, className, children, ...rest } = props;
const Tag: HeadingTag = `h${level}`;
const resolvedClass = clsx(typographyClassMap[typography], className);
return (
<Tag className={resolvedClass} {...rest}>
{children}
</Tag>
);
}
export const Heading = memo(HeadingInner);

View File

@ -0,0 +1,64 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC
*/
import { render } from "@testing-library/react";
import { Text } from "./Text";
describe("Text", () => {
it("should render successfully", () => {
const { baseElement } = render(<Text typography="body-medium">Hello</Text>);
expect(baseElement).toBeTruthy();
});
it("should render with default 'p' tag", () => {
const { container } = render(<Text typography="body-medium">Hello</Text>);
const element = container.querySelector("p");
expect(element).toBeTruthy();
expect(element?.textContent).toBe("Hello");
});
it("should render with a custom tag via 'as' prop", () => {
const { container } = render(
<Text as="span" typography="display">
Custom
</Text>,
);
const element = container.querySelector("span");
expect(element).toBeTruthy();
expect(element?.textContent).toBe("Custom");
});
it("should apply typography class", () => {
const { container } = render(<Text typography="body-medium">Styled</Text>);
const element = container.querySelector("p");
expect(element?.className).toContain("body-medium-typography");
});
it("should merge custom className with typography class", () => {
const { container } = render(
<Text typography="display" className="custom-class">
Merged
</Text>,
);
const element = container.querySelector("p");
expect(element?.className).toContain("display-typography");
expect(element?.className).toContain("custom-class");
});
it("should pass through additional HTML attributes", () => {
const { container } = render(
<Text typography="body-small" data-testid="my-text" id="text-1">
Attrs
</Text>,
);
const element = container.querySelector("p");
expect(element?.getAttribute("data-testid")).toBe("my-text");
expect(element?.getAttribute("id")).toBe("text-1");
});
});

View File

@ -0,0 +1,126 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Text } from "./Text";
import { typographyIds } from "./typography";
const meta = {
title: "Foundations/Typography/Text",
component: Text,
argTypes: {
typography: {
options: typographyIds,
control: { type: "select" },
},
as: {
control: { type: "text" },
},
},
parameters: {
controls: { exclude: ["children"] },
},
args: {
children: "Lorem ipsum",
},
render: ({ children, ...args }) => <Text {...args}>{children}</Text>,
} satisfies Meta<typeof Text>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
typography: "display",
},
};
export const CustomTag: Story = {
args: {
typography: "display",
as: "li",
},
};
export const Display: Story = {
args: {
typography: "display",
children: "Display 400 36px/1.4 Work Sans",
},
};
export const TitleLarge: Story = {
args: {
typography: "title-large",
children: "Title Large 400 24px/1.4 Work Sans",
},
};
export const TitleMedium: Story = {
args: {
typography: "title-medium",
children: "Title Medium 400 20px/1.4 Work Sans",
},
};
export const TitleSmall: Story = {
args: {
typography: "title-small",
children: "Title Small 400 14px/1.2 Work Sans",
},
};
export const HeadlineLarge: Story = {
args: {
typography: "headline-large",
children: "Headline Large 400 18px/1.4 Work Sans",
},
};
export const HeadlineMedium: Story = {
args: {
typography: "headline-medium",
children: "Headline Medium 400 16px/1.4 Work Sans",
},
};
export const HeadlineSmall: Story = {
args: {
typography: "headline-small",
children: "Headline Small 500 12px/1.2 Work Sans",
},
};
export const BodyLarge: Story = {
args: {
typography: "body-large",
children: "Body Large 400 16px/1.4 Work Sans",
},
};
export const BodyMedium: Story = {
args: {
typography: "body-medium",
children: "Body Medium 400 14px/1.3 Work Sans",
},
};
export const BodySmall: Story = {
args: {
typography: "body-small",
children: "Body Small 400 12px/1.3 Work Sans",
},
};
export const CodeFont: Story = {
args: {
typography: "code-font",
children: "Code Font 400 12px/1.2 Roboto Mono",
},
};

View File

@ -0,0 +1,37 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC
*/
import { type ComponentPropsWithRef, type ElementType, memo } from "react";
import clsx from "clsx";
import type { TypographyId } from "./typography";
import { typographyClassMap } from "./typographyClassMap";
type TextOwnProps<T extends ElementType = "p"> = {
/** The HTML element or component to render. Defaults to `"p"`. */
as?: T;
/** The typography style to apply. */
typography: TypographyId;
};
export type TextProps<T extends ElementType = "p"> = TextOwnProps<T> &
Omit<ComponentPropsWithRef<T>, keyof TextOwnProps<T>>;
function TextInner<T extends ElementType = "p">(props: TextProps<T>) {
const { as, typography, className, children, ...rest } = props;
const Tag = as ?? "p";
const resolvedClass = clsx(typographyClassMap[typography], className);
return (
<Tag className={resolvedClass} {...rest}>
{children}
</Tag>
);
}
export const Text = memo(TextInner) as typeof TextInner;

View File

@ -0,0 +1,51 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "../../_ds/typography" as t;
.display-typography {
@include t.use-typography("display");
}
.title-large-typography {
@include t.use-typography("title-large");
}
.title-medium-typography {
@include t.use-typography("title-medium");
}
.title-small-typography {
@include t.use-typography("title-small");
}
.headline-large-typography {
@include t.use-typography("headline-large");
}
.headline-medium-typography {
@include t.use-typography("headline-medium");
}
.headline-small-typography {
@include t.use-typography("headline-small");
}
.body-large-typography {
@include t.use-typography("body-large");
}
.body-medium-typography {
@include t.use-typography("body-medium");
}
.body-small-typography {
@include t.use-typography("body-small");
}
.code-font-typography {
@include t.use-typography("code-font");
}

View File

@ -0,0 +1,27 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC
*/
export const Typography = {
display: "display",
titleLarge: "title-large",
titleMedium: "title-medium",
titleSmall: "title-small",
headlineLarge: "headline-large",
headlineMedium: "headline-medium",
headlineSmall: "headline-small",
bodyLarge: "body-large",
bodyMedium: "body-medium",
bodySmall: "body-small",
codeFont: "code-font",
} as const;
export type TypographyId = (typeof Typography)[keyof typeof Typography];
export const typographyIds: TypographyId[] = Object.values(Typography);
export const typographySet: ReadonlySet<string> = new Set(typographyIds);

View File

@ -0,0 +1,24 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC
*/
import styles from "./typography.module.scss";
/** Maps a TypographyId value to its corresponding CSS-module class name. */
export const typographyClassMap: Readonly<Record<string, string>> = {
display: styles["display-typography"],
"title-large": styles["title-large-typography"],
"title-medium": styles["title-medium-typography"],
"title-small": styles["title-small-typography"],
"headline-large": styles["headline-large-typography"],
"headline-medium": styles["headline-medium-typography"],
"headline-small": styles["headline-small-typography"],
"body-large": styles["body-large-typography"],
"body-medium": styles["body-medium-typography"],
"body-small": styles["body-small-typography"],
"code-font": styles["code-font-typography"],
};

View File

@ -0,0 +1,25 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "../_ds/_borders.scss" as *;
@use "../_ds/typography.scss" as t;
.cta {
border-radius: $br-8;
border: $b-1 solid var(--color-accent-primary-muted);
background: var(--color-accent-select);
padding: var(--sp-m);
}
.cta-title {
color: var(--color-foreground-primary);
line-height: 1.2;
margin-block-end: var(--sp-m);
}
.cta-message {
line-height: 100%;
}

View File

@ -0,0 +1,55 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC
*/
import { render } from "@testing-library/react";
import { Cta } from "./Cta";
describe("Cta", () => {
it("should render successfully", () => {
const { baseElement } = render(<Cta title="Hello" />);
expect(baseElement).toBeTruthy();
});
it("should render the title text", () => {
const { getByText } = render(<Cta title="Upgrade your plan" />);
expect(getByText("Upgrade your plan")).toBeTruthy();
});
it("should render children in the message area", () => {
const { getByText } = render(
<Cta title="Notice">
<span>Read more details here</span>
</Cta>,
);
expect(getByText("Read more details here")).toBeTruthy();
});
it("should render with data-testid attribute", () => {
const { container } = render(<Cta title="Test" />);
const element = container.querySelector("[data-testid='cta']");
expect(element).toBeTruthy();
});
it("should merge custom className", () => {
const { container } = render(
<Cta title="Styled" className="custom-class" />,
);
const element = container.querySelector("[data-testid='cta']");
expect(element?.className).toContain("custom-class");
});
it("should pass through additional HTML attributes", () => {
const { container } = render(
<Cta title="Attrs" id="my-cta" aria-label="call to action" />,
);
const element = container.querySelector("[data-testid='cta']");
expect(element?.getAttribute("id")).toBe("my-cta");
expect(element?.getAttribute("aria-label")).toBe("call to action");
});
});

View File

@ -0,0 +1,46 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Cta } from "./Cta";
const meta = {
title: "Product/CTA",
component: Cta,
argTypes: {
title: {
control: { type: "text" },
},
},
args: {
title: "Autosaved versions will be kept for 7 days.",
},
render: ({ children, ...args }) => (
<Cta {...args}>
{children ?? (
<span
style={{
fontSize: "0.75rem",
color: "var(--color-foreground-secondary)",
}}
>
If you&apos;d like to increase this limit, write to us at{" "}
<a style={{ color: "var(--color-accent-primary)" }} href="#">
support@penpot.app
</a>
</span>
)}
</Cta>
),
} satisfies Meta<typeof Cta>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View File

@ -0,0 +1,37 @@
/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) KALEIDOS INC
*/
import { type ComponentPropsWithRef, memo } from "react";
import clsx from "clsx";
import { Text } from "../foundations/typography/Text";
import styles from "./Cta.module.scss";
export interface CtaProps extends ComponentPropsWithRef<"div"> {
/** The title text displayed at the top of the CTA block. */
title: string;
}
function CtaInner({ title, className, children, ...rest }: CtaProps) {
return (
<div className={clsx(styles.cta, className)} data-testid="cta" {...rest}>
<div className={styles["cta-title"]}>
<Text
as="span"
typography="headline-small"
className={styles["placeholder-title"]}
>
{title}
</Text>
</div>
<div className={styles["cta-message"]}>{children}</div>
</div>
);
}
export const Cta = memo(CtaInner);

View File

@ -0,0 +1,37 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import postcssScss from "postcss-scss";
/** @type {import("stylelint").Config} */
export default {
extends: ["stylelint-config-standard-scss"],
plugins: ["stylelint-scss", "stylelint-use-logical-spec"],
overrides: [
{
files: ["**/*.scss"],
customSyntax: postcssScss,
},
],
rules: {
"at-rule-no-unknown": null,
"declaration-property-value-no-unknown": null,
"selector-pseudo-class-no-unknown": [
true,
{ ignorePseudoClasses: ["global"] },
],
// scss
"scss/comment-no-empty": null,
"scss/at-rule-no-unknown": true,
// TODO: this rule should be enabled to follow scss conventions
"scss/load-no-partial-leading-underscore": null,
// This allows using the characters - or _ as a prefix and is ISO compliant with the Sass specification.
"scss/dollar-variable-pattern": "^[-_]?([a-z][a-z0-9]*)(-[a-z0-9]+)*$",
// This allows using the characters - or _ as a prefix and is ISO compliant with the Sass specification.
"scss/at-mixin-pattern": "^[-_]?([a-z][a-z0-9]*)(-[a-z0-9]+)*$",
},
};

View File

@ -14,8 +14,7 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"jsx": "react-jsx",
"types": ["vite/client", "vitest"],
"baseUrl": "."
"types": ["vite/client", "vitest"]
},
"files": [],
"include": [],

View File

@ -1,7 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDecoratorMetadata": true,
"outDir": "",
"module": "esnext",
"moduleResolution": "bundler"

View File

@ -1,6 +1,6 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
import { copyFileSync } from 'node:fs';
@ -22,11 +22,8 @@ const copyCssPlugin = () => ({
export default defineConfig(() => ({
root: import.meta.dirname,
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
react(),
reactCompilerPreset(),
dts({
entryRoot: 'src',
tsconfigPath: path.join(import.meta.dirname, 'tsconfig.lib.json'),

237
frontend/pnpm-lock.yaml generated
View File

@ -317,9 +317,15 @@ importers:
babel-plugin-react-compiler:
specifier: ^1.0.0
version: 1.0.0
clsx:
specifier: ^2.1.1
version: 2.1.1
eslint:
specifier: ^9.39.2
version: 9.39.2
eslint-plugin-import:
specifier: 2.32.0
version: 2.32.0(eslint@9.39.2)
version: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2)(typescript@6.0.2))(eslint@9.39.2)
eslint-plugin-jsx-a11y:
specifier: 6.10.2
version: 6.10.2(eslint@9.39.2)
@ -329,15 +335,45 @@ importers:
eslint-plugin-react-hooks:
specifier: 7.0.1
version: 7.0.1(eslint@9.39.2)
postcss-scss:
specifier: ^4.0.9
version: 4.0.9(postcss@8.5.8)
prettier:
specifier: 3.8.1
version: 3.8.1
react-compiler-runtime:
specifier: ^1.0.0
version: 1.0.0(react@19.2.4)
sass:
specifier: ^1.98.0
version: 1.98.0
storybook:
specifier: 10.3.5
version: 10.3.5(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
stylelint:
specifier: ^17.4.0
version: 17.4.0(typescript@6.0.2)
stylelint-config-standard-scss:
specifier: ^17.0.0
version: 17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@6.0.2))
stylelint-scss:
specifier: ^7.0.0
version: 7.0.0(stylelint@17.4.0(typescript@6.0.2))
stylelint-use-logical-spec:
specifier: ^5.0.1
version: 5.0.1(stylelint@17.4.0(typescript@6.0.2))
typescript:
specifier: ^6.0.2
version: 6.0.2
typescript-eslint:
specifier: ^8.58.0
version: 8.58.0(eslint@9.39.2)(typescript@6.0.2)
vite-plugin-dts:
specifier: ^4.5.4
version: 4.5.4(@types/node@25.5.2)(rollup@4.57.1)(typescript@6.0.2)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))
vitest:
specifier: ^4.1.3
version: 4.1.3(@types/node@25.5.2)(@vitest/browser-playwright@4.1.3)(@vitest/coverage-v8@4.1.3)(jsdom@29.0.2(canvas@3.2.1))(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))
text-editor:
devDependencies:
@ -2264,6 +2300,65 @@ packages:
'@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
'@typescript-eslint/eslint-plugin@8.58.0':
resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.58.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/parser@8.58.0':
resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/project-service@8.58.0':
resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/scope-manager@8.58.0':
resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.58.0':
resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/type-utils@8.58.0':
resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/types@8.58.0':
resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.58.0':
resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/utils@8.58.0':
resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/visitor-keys@8.58.0':
resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-react@6.0.1':
resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -2827,6 +2922,10 @@ packages:
resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==}
engines: {node: '>=6'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
@ -3402,6 +3501,10 @@ packages:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint-visitor-keys@5.0.1:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@9.39.2:
resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -5908,6 +6011,12 @@ packages:
resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==}
engines: {node: '>= 14.0.0'}
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
ts-dedent@2.2.0:
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
engines: {node: '>=6.10'}
@ -5953,6 +6062,13 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typescript-eslint@8.58.0:
resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
typescript@5.8.2:
resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==}
engines: {node: '>=14.17'}
@ -7921,6 +8037,97 @@ snapshots:
'@types/triple-beam@1.3.5': {}
'@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2)(typescript@6.0.2))(eslint@9.39.2)(typescript@6.0.2)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.58.0(eslint@9.39.2)(typescript@6.0.2)
'@typescript-eslint/scope-manager': 8.58.0
'@typescript-eslint/type-utils': 8.58.0(eslint@9.39.2)(typescript@6.0.2)
'@typescript-eslint/utils': 8.58.0(eslint@9.39.2)(typescript@6.0.2)
'@typescript-eslint/visitor-keys': 8.58.0
eslint: 9.39.2
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@6.0.2)
typescript: 6.0.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.58.0(eslint@9.39.2)(typescript@6.0.2)':
dependencies:
'@typescript-eslint/scope-manager': 8.58.0
'@typescript-eslint/types': 8.58.0
'@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2)
'@typescript-eslint/visitor-keys': 8.58.0
debug: 4.4.3(supports-color@5.5.0)
eslint: 9.39.2
typescript: 6.0.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.58.0(typescript@6.0.2)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2)
'@typescript-eslint/types': 8.58.0
debug: 4.4.3(supports-color@5.5.0)
typescript: 6.0.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.58.0':
dependencies:
'@typescript-eslint/types': 8.58.0
'@typescript-eslint/visitor-keys': 8.58.0
'@typescript-eslint/tsconfig-utils@8.58.0(typescript@6.0.2)':
dependencies:
typescript: 6.0.2
'@typescript-eslint/type-utils@8.58.0(eslint@9.39.2)(typescript@6.0.2)':
dependencies:
'@typescript-eslint/types': 8.58.0
'@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2)
'@typescript-eslint/utils': 8.58.0(eslint@9.39.2)(typescript@6.0.2)
debug: 4.4.3(supports-color@5.5.0)
eslint: 9.39.2
ts-api-utils: 2.5.0(typescript@6.0.2)
typescript: 6.0.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.58.0': {}
'@typescript-eslint/typescript-estree@8.58.0(typescript@6.0.2)':
dependencies:
'@typescript-eslint/project-service': 8.58.0(typescript@6.0.2)
'@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.2)
'@typescript-eslint/types': 8.58.0
'@typescript-eslint/visitor-keys': 8.58.0
debug: 4.4.3(supports-color@5.5.0)
minimatch: 10.2.5
semver: 7.7.4
tinyglobby: 0.2.15
ts-api-utils: 2.5.0(typescript@6.0.2)
typescript: 6.0.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.58.0(eslint@9.39.2)(typescript@6.0.2)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2)
'@typescript-eslint/scope-manager': 8.58.0
'@typescript-eslint/types': 8.58.0
'@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2)
eslint: 9.39.2
typescript: 6.0.2
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.58.0':
dependencies:
'@typescript-eslint/types': 8.58.0
eslint-visitor-keys: 5.0.1
'@vitejs/plugin-react@6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.7(@types/node@25.5.2)(esbuild@0.28.0)(sass-embedded@1.98.0)(sass@1.98.0)(yaml@2.8.2))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
@ -8620,6 +8827,8 @@ snapshots:
clsx@1.2.1: {}
clsx@2.1.1: {}
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
@ -9267,16 +9476,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.10)(eslint@9.39.2):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.2)(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint@9.39.2):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.58.0(eslint@9.39.2)(typescript@6.0.2)
eslint: 9.39.2
eslint-import-resolver-node: 0.3.10
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(eslint@9.39.2):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2)(typescript@6.0.2))(eslint@9.39.2):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@ -9287,7 +9497,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.2
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.10)(eslint@9.39.2)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.2)(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint@9.39.2)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@ -9298,6 +9508,8 @@ snapshots:
semver: 6.3.1
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.58.0(eslint@9.39.2)(typescript@6.0.2)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@ -9364,6 +9576,8 @@ snapshots:
eslint-visitor-keys@4.2.1: {}
eslint-visitor-keys@5.0.1: {}
eslint@9.39.2:
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2)
@ -12042,6 +12256,10 @@ snapshots:
triple-beam@1.4.1: {}
ts-api-utils@2.5.0(typescript@6.0.2):
dependencies:
typescript: 6.0.2
ts-dedent@2.2.0: {}
tsconfig-paths@3.15.0:
@ -12108,6 +12326,17 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typescript-eslint@8.58.0(eslint@9.39.2)(typescript@6.0.2):
dependencies:
'@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.2)(typescript@6.0.2))(eslint@9.39.2)(typescript@6.0.2)
'@typescript-eslint/parser': 8.58.0(eslint@9.39.2)(typescript@6.0.2)
'@typescript-eslint/typescript-estree': 8.58.0(typescript@6.0.2)
'@typescript-eslint/utils': 8.58.0(eslint@9.39.2)(typescript@6.0.2)
eslint: 9.39.2
typescript: 6.0.2
transitivePeerDependencies:
- supports-color
typescript@5.8.2: {}
typescript@6.0.2: {}