fix: storybook font rendering and shared fonts module #1

Merged
ilia merged 74 commits from feat/portfolio-setup into main 2026-05-18 18:45:22 +00:00
2 changed files with 66 additions and 8 deletions
Showing only changes of commit ba7395cb32 - Show all commits
+27
View File
@@ -63,4 +63,31 @@ describe('Button', () => {
expect(screen.getByRole('button')).toHaveClass('w-full'); expect(screen.getByRole('button')).toHaveClass('w-full');
}); });
}); });
describe('as anchor', () => {
it('renders an anchor when href is provided', () => {
render(<Button href="/cv.pdf">Download</Button>);
expect(screen.getByRole('link', { name: 'Download' })).toBeInTheDocument();
});
it('sets href on the anchor', () => {
render(<Button href="/cv.pdf">Download</Button>);
expect(screen.getByRole('link')).toHaveAttribute('href', '/cv.pdf');
});
it('sets download attribute when provided', () => {
render(
<Button href="/cv.pdf" download>
Download
</Button>,
);
expect(screen.getByRole('link')).toHaveAttribute('download');
});
it('applies the same variant and size classes as button', () => {
render(
<Button href="/test" variant="primary" size="sm">
Go
</Button>,
);
const link = screen.getByRole('link');
expect(link).toHaveClass('bg-blue', 'px-4', 'py-2');
});
});
}); });
+39 -8
View File
@@ -1,10 +1,10 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'; export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg'; export type ButtonSize = 'sm' | 'md' | 'lg';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> { type BaseProps = {
/** /**
* Visual variant * Visual variant
* @default 'primary' * @default 'primary'
@@ -19,9 +19,28 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
* Button content * Button content
*/ */
children: ReactNode; children: ReactNode;
/**
* CSS classes
*/
className?: string;
};
type AsButton = BaseProps & ButtonHTMLAttributes<HTMLButtonElement> & { href?: never };
type AsAnchor = BaseProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
type Props = AsButton | AsAnchor;
type RestButton = Omit<AsButton, keyof BaseProps>;
type RestAnchor = Omit<AsAnchor, keyof BaseProps>;
/**
* Narrows spread props to anchor shape when href is a non-undefined string.
*/
function isAnchorProps(props: RestButton | RestAnchor): props is RestAnchor {
return typeof props.href === 'string';
} }
const VARIANTS: Record<ButtonVariant, string> = { const VARIANTS = {
primary: primary:
'brutal-border bg-blue text-cream shadow-[5px_5px_0_rgba(4,28,243,0.35)] hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_rgba(4,28,243,0.35)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-[1px_1px_0_rgba(4,28,243,0.35)]', 'brutal-border bg-blue text-cream shadow-[5px_5px_0_rgba(4,28,243,0.35)] hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_rgba(4,28,243,0.35)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-[1px_1px_0_rgba(4,28,243,0.35)]',
secondary: secondary:
@@ -29,14 +48,14 @@ const VARIANTS: Record<ButtonVariant, string> = {
outline: outline:
'brutal-border bg-transparent text-blue shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs', 'brutal-border bg-transparent text-blue shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
ghost: ghost:
'border-[3px] border-solid border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream', 'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
}; } as const satisfies Record<ButtonVariant, string>;
const SIZES: Record<ButtonSize, string> = { const SIZES = {
sm: 'px-4 py-2 text-sm', sm: 'px-4 py-2 text-sm',
md: 'px-6 py-3 text-base', md: 'px-6 py-3 text-base',
lg: 'px-8 py-4 text-lg', lg: 'px-8 py-4 text-lg',
}; } as const satisfies Record<ButtonSize, string>;
/* box-shadow excluded from transition intentionally — snaps instantly so the /* box-shadow excluded from transition intentionally — snaps instantly so the
* eye follows the 130ms button movement, not the shadow change. */ * eye follows the 130ms button movement, not the shadow change. */
@@ -44,10 +63,22 @@ const BASE = 'cursor-pointer btn-transition uppercase tracking-wider';
/** /**
* Brutalist button with variants and sizes. * Brutalist button with variants and sizes.
* Renders as <a> when href is provided, <button> otherwise.
*/ */
export function Button({ variant = 'primary', size = 'md', className, children, ...props }: Props) { export function Button({ variant = 'primary', size = 'md', className, children, ...props }: Props) {
const cls = cn(BASE, VARIANTS[variant], SIZES[size], className);
if (isAnchorProps(props)) {
const { href, ...anchorProps } = props;
return (
<a href={href} rel="noopener noreferrer" target="_blank" className={cls} {...anchorProps}>
{children}
</a>
);
}
return ( return (
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}> <button className={cls} {...props}>
{children} {children}
</button> </button>
); );