Add v2 Text typography primitive

Implement the first v2 typography component from the Probo Radix UI
Figma: a Text primitive rendering a single span with size, weight,
align, color, and highContrast variants (color x highContrast
resolves to the sand/gold/red/green/amber/sky text step 11 or 12),
plus a paired TextSkeleton. Styling is tailwind-variants only, with
the scale shared through a variants module per the kit guides.

Stand up a theme-isolated v2 Storybook (.storybook-v2) loading the
v2 theme with a light/dark toggle, since v1 and v2 cannot share one
Tailwind build; the v1 build now excludes src/v2.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-24 11:50:39 +02:00
parent 4656469c47
commit 863e41c47f
10 changed files with 380 additions and 2 deletions

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { StorybookConfig } from "@storybook/react-vite";
// The v2 kit is theme-isolated from v1 (v2 wipes Tailwind's palette), so it has
// its own Storybook build scanning only src/v2 and loading the v2 theme.
const config: StorybookConfig = {
stories: ["../src/v2/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [import.meta.resolve("@chromatic-com/storybook")],
framework: {
name: "@storybook/react-vite",
options: {},
},
};
export default config;

View File

@@ -0,0 +1,18 @@
/* Copyright (c) 2026 Probo Inc <hello@probo.com>.
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*/
@import "tailwindcss";
@import "../src/v2/theme.css";
@source "../src/v2";

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { Preview } from "@storybook/react";
import { useEffect } from "react";
import "./preview.css";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
globalTypes: {
theme: {
description: "v2 color theme",
defaultValue: "light",
toolbar: {
title: "Theme",
icon: "circlehollow",
items: [
{ value: "light", title: "Light" },
{ value: "dark", title: "Dark" },
],
dynamicTitle: true,
},
},
},
decorators: [
(Story, context) => {
const isDark = context.globals.theme === "dark";
useEffect(() => {
document.documentElement.classList.toggle("dark", isDark);
}, [isDark]);
return (
<div className="min-h-screen bg-sand-1 p-8 text-sand-12">
<Story />
</div>
);
},
],
};
export default preview;

View File

@@ -15,7 +15,11 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
// v2 components have their own theme-isolated Storybook (.storybook-v2).
stories: [
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)",
"!../src/v2/**",
],
addons: [
import.meta.resolve("@chromatic-com/storybook"),
import.meta.resolve("@storybook/addon-vitest"),

View File

@@ -7,6 +7,8 @@
"main": "src/index.ts",
"scripts": {
"dev": "storybook dev -p 6006 --no-open",
"dev:v2": "storybook dev -p 6007 -c .storybook-v2 --no-open",
"build:v2": "storybook build -c .storybook-v2",
"check": "tsc --noEmit -p tsconfig.app.json",
"icons": "bun run src/Atoms/Icons/generator.ts"
},

View File

@@ -0,0 +1,111 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { Meta, StoryObj } from "@storybook/react";
import { Text } from "./Text";
import { TextSkeleton } from "./TextSkeleton";
const sample = "The quick brown fox jumps over the lazy dog.";
const sizes = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
const weights = ["light", "regular", "medium", "bold"] as const;
const colors = ["neutral", "gold", "red", "green", "amber", "sky"] as const;
export default {
title: "v2/Typography/Text",
component: Text,
args: {
children: sample,
size: 3,
weight: "regular",
color: "neutral",
highContrast: false,
},
} satisfies Meta<typeof Text>;
type Story = StoryObj<typeof Text>;
export const Playground: Story = {};
export const Sizes: Story = {
render: () => (
<div className="flex flex-col gap-3">
{sizes.map(size => (
<Text key={size} className="block" size={size}>
{size}
{" — "}
{sample}
</Text>
))}
</div>
),
};
export const Weights: Story = {
render: () => (
<div className="flex flex-col gap-3">
{weights.map(weight => (
<Text key={weight} className="block" weight={weight}>
{weight}
{" — "}
{sample}
</Text>
))}
</div>
),
};
export const Align: Story = {
render: () => (
<div className="flex w-96 flex-col gap-3">
<Text className="block" align="left">Left-aligned</Text>
<Text className="block" align="center">Center-aligned</Text>
<Text className="block" align="right">Right-aligned</Text>
</div>
),
};
export const Colors: Story = {
render: () => (
<div className="flex flex-col gap-3">
{colors.map(color => (
<Text key={color} className="block" color={color}>
{color}
{" — "}
{sample}
</Text>
))}
</div>
),
};
export const HighContrast: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Text className="block">Low contrast (step 11)</Text>
<Text className="block" highContrast>High contrast (step 12)</Text>
</div>
),
};
export const Skeleton: Story = {
render: () => (
<div className="flex w-96 flex-col gap-3">
<TextSkeleton size={6} className="w-1/2" />
<TextSkeleton size={3} />
<TextSkeleton size={3} className="w-2/3" />
</div>
),
};

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { ComponentProps } from "react";
import type { VariantProps } from "tailwind-variants";
import { text } from "./variants";
export type TextProps = ComponentProps<"span"> & VariantProps<typeof text>;
// Foundational text primitive (Radix "Text"). Renders a single <span>; reach
// for size/weight/align/color/highContrast variants rather than raw type
// utilities. See contrib/claude/ui.md (typography).
export function Text(props: TextProps) {
const { size, weight, align, color, highContrast, className, ...rest } = props;
return (
<span
className={text({ size, weight, align, color, highContrast, className })}
{...rest}
/>
);
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { ComponentProps } from "react";
import type { VariantProps } from "tailwind-variants";
import { textSkeleton } from "./variants";
export type TextSkeletonProps = Omit<ComponentProps<"span">, "children"> & VariantProps<typeof textSkeleton>;
// Loading placeholder paired with Text: a pulse block matching a line of text
// at the given size. Defaults to full width; constrain with a width class.
export function TextSkeleton(props: TextSkeletonProps) {
const { size, className, ...rest } = props;
return (
<span aria-hidden className={textSkeleton({ size, className })} {...rest}>
{"\u00A0"}
</span>
);
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { tv } from "tailwind-variants";
// Numbered type scale (text-1 … text-9). Each utility carries its paired
// font-size, line-height, and letter-spacing from the v2 theme. Shared between
// Text and its skeleton so the placeholder matches the real line height.
const size = {
1: "text-1",
2: "text-2",
3: "text-3",
4: "text-4",
5: "text-5",
6: "text-6",
7: "text-7",
8: "text-8",
9: "text-9",
} as const;
export const text = tv({
variants: {
size,
weight: {
light: "font-light",
regular: "font-normal",
medium: "font-medium",
bold: "font-bold",
},
align: {
left: "text-left",
center: "text-center",
right: "text-right",
},
// Hue only; the resolved text step comes from the color × highContrast
// compound variants below (step 11 low-contrast, step 12 high-contrast).
color: {
neutral: "",
gold: "",
red: "",
green: "",
amber: "",
sky: "",
},
highContrast: {
true: "",
false: "",
},
},
compoundVariants: [
{ color: "neutral", highContrast: false, class: "text-sand-11" },
{ color: "neutral", highContrast: true, class: "text-sand-12" },
{ color: "gold", highContrast: false, class: "text-gold-11" },
{ color: "gold", highContrast: true, class: "text-gold-12" },
{ color: "red", highContrast: false, class: "text-red-11" },
{ color: "red", highContrast: true, class: "text-red-12" },
{ color: "green", highContrast: false, class: "text-green-11" },
{ color: "green", highContrast: true, class: "text-green-12" },
{ color: "amber", highContrast: false, class: "text-amber-11" },
{ color: "amber", highContrast: true, class: "text-amber-12" },
{ color: "sky", highContrast: false, class: "text-sky-11" },
{ color: "sky", highContrast: true, class: "text-sky-12" },
],
defaultVariants: {
size: 3,
weight: "regular",
color: "neutral",
highContrast: false,
},
});
export const textSkeleton = tv({
base: "inline-block w-full animate-pulse select-none rounded-2 bg-sand-3 text-transparent",
variants: {
size,
},
defaultVariants: {
size: 3,
},
});

View File

@@ -23,5 +23,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src", ".storybook/**/*"],
"include": ["src", ".storybook/**/*", ".storybook-v2/**/*"],
}