diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index b17a483..8e45628 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,6 +3,7 @@ import type { Preview } from '@storybook/react'; import { ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import { theme } from '../src/theme'; +import '../src/tailwind.css'; import '../src/theme/generated/tokens.css'; const preview: Preview = { diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/tailwind.css b/src/tailwind.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/src/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/theme/generated/tailwind-tokens.js b/src/theme/generated/tailwind-tokens.js new file mode 100644 index 0000000..869e95a --- /dev/null +++ b/src/theme/generated/tailwind-tokens.js @@ -0,0 +1,160 @@ +/** + * Auto-generated Tailwind tokens from Style Dictionary. + * Do not edit directly — edit tokens/*.json and run npm run build:tokens. + */ +module.exports = { + "colors": { + "brand": { + "50": "#fef9f5", + "100": "#f7ecdf", + "200": "#ebdac8", + "300": "#d8c3b5", + "400": "#d0a070", + "500": "#ba834e", + "600": "#b0610f", + "700": "#8b4e0d", + "800": "#6b3c13", + "900": "#51301b", + "950": "#251913" + }, + "sage": { + "50": "#f2f5f6", + "100": "#e3eaeb", + "200": "#d7e1e2", + "300": "#c8d4d6", + "400": "#b9c7c9", + "500": "#8ea2a7", + "600": "#687d84", + "700": "#4c5b6b", + "800": "#4c5459", + "900": "#343c40", + "950": "#1e2528" + }, + "neutral": { + "50": "#fafafa", + "100": "#f5f5f5", + "200": "#e8e8e8", + "300": "#d4d4d4", + "400": "#a3a3a3", + "500": "#737373", + "600": "#525252", + "700": "#404040", + "800": "#2c2e35", + "900": "#1a1a1c", + "950": "#0a0a0b" + }, + "red": { + "50": "#fef2f2", + "100": "#fde8e8", + "200": "#f9bfbf", + "300": "#f09898", + "400": "#e56b6b", + "500": "#d64545", + "600": "#bc2f2f", + "700": "#9b2424", + "800": "#7a1d1d", + "900": "#5c1616", + "950": "#3d0e0e" + }, + "amber": { + "50": "#fff9eb", + "100": "#fff0cc", + "200": "#ffe099", + "300": "#ffcc66", + "400": "#ffb833", + "500": "#f5a000", + "600": "#cc8500", + "700": "#a36b00", + "800": "#7a5000", + "900": "#523600", + "950": "#331f00" + }, + "green": { + "50": "#f0f7f0", + "100": "#d8ecd8", + "200": "#b8d8b8", + "300": "#8dc08d", + "400": "#66a866", + "500": "#4a8f4a", + "600": "#3b7a3b", + "700": "#2e6b2e", + "800": "#235523", + "900": "#1a3f1a", + "950": "#0f2a0f" + }, + "blue": { + "50": "#eff6ff", + "100": "#dbeafe", + "200": "#bfdbfe", + "300": "#93c5fd", + "400": "#60a5fa", + "500": "#3b82f6", + "600": "#2563eb", + "700": "#1d4ed8", + "800": "#1e40af", + "900": "#1e3a8a", + "950": "#172554" + }, + "white": "#ffffff", + "black": "#000000" + }, + "spacing": { + "1": "4px", + "2": "8px", + "3": "12px", + "4": "16px", + "5": "20px", + "6": "24px", + "8": "32px", + "10": "40px", + "12": "48px", + "16": "64px", + "20": "80px", + "05": "2px" + }, + "borderRadius": { + "none": "0px", + "sm": "4px", + "md": "8px", + "lg": "12px", + "xl": "16px", + "full": "9999px" + }, + "fontFamily": { + "body": "'Montserrat', 'Helvetica Neue', Arial, sans-serif", + "display": "'Noto Serif SC', Georgia, 'Times New Roman', serif", + "mono": "'JetBrains Mono', 'Fira Code', Consolas, monospace" + }, + "fontSize": { + "2xs": "0.6875rem", + "xs": "0.75rem", + "sm": "0.875rem", + "base": "1rem", + "md": "1.125rem", + "lg": "1.25rem", + "xl": "1.5rem", + "2xl": "1.875rem", + "3xl": "2.25rem", + "4xl": "3rem" + }, + "fontWeight": { + "regular": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + }, + "lineHeight": { + "tight": 1.25, + "snug": 1.375, + "normal": 1.5, + "relaxed": 1.75 + }, + "letterSpacing": { + "tighter": "-0.02em", + "tight": "-0.01em", + "normal": "0em", + "wide": "0.02em", + "wider": "0.05em", + "widest": "0.08em" + } +}; diff --git a/style-dictionary/config.js b/style-dictionary/config.js index 8a42612..abd263e 100644 --- a/style-dictionary/config.js +++ b/style-dictionary/config.js @@ -4,17 +4,18 @@ * Transforms W3C DTCG token JSON into: * - CSS custom properties (for runtime theming) * - JavaScript ES6 module (for MUI theme consumption) + * - Tailwind token module (for tailwind.config.js consumption) * - JSON (for Penpot import) */ import StyleDictionary from 'style-dictionary'; +import { readFileSync, writeFileSync } from 'fs'; const sd = new StyleDictionary({ source: ['tokens/**/*.json'], usesDtcg: true, platforms: { - // CSS custom properties css: { transformGroup: 'css', prefix: 'fa', @@ -28,7 +29,6 @@ const sd = new StyleDictionary({ }], }, - // JavaScript ES6 module for MUI theme js: { transformGroup: 'js', buildPath: 'src/theme/generated/', @@ -38,7 +38,6 @@ const sd = new StyleDictionary({ }], }, - // Flat JSON for Penpot import and other tools json: { transformGroup: 'js', buildPath: 'tokens/export/', @@ -53,13 +52,11 @@ const sd = new StyleDictionary({ await sd.buildAllPlatforms(); // Generate TypeScript declarations for the JS token output -import { readFileSync, writeFileSync } from 'fs'; - const jsPath = 'src/theme/generated/tokens.js'; const dtsPath = 'src/theme/generated/tokens.d.ts'; const jsContent = readFileSync(jsPath, 'utf-8') - .replace(/=\n\s+/g, '= '); // join continuation lines + .replace(/=\n\s+/g, '= '); const declarations = ['/**', ' * Do not edit directly, this file was auto-generated.', ' */']; @@ -73,3 +70,102 @@ for (const line of jsContent.split('\n')) { writeFileSync(dtsPath, declarations.join('\n') + '\n'); console.log(`✓ Generated ${declarations.length - 3} TypeScript declarations`); + +// Generate Tailwind token module from the flat JSON export +// Keys are PascalCase: ColorBrand500, Spacing4, FontFamilyBody, etc. +const flatTokens = JSON.parse(readFileSync('tokens/export/tokens-flat.json', 'utf-8')); + +const twColors = {}; +const twSpacing = {}; +const twBorderRadius = {}; +const twFontFamily = {}; +const twFontSize = {}; +const twFontWeight = {}; +const twLineHeight = {}; +const twLetterSpacing = {}; + +// Helper: split PascalCase into parts, e.g. "ColorBrand500" → ["Color", "Brand", "500"] +function splitPascal(str) { + return str.match(/[A-Z][a-z]*|\d+/g) || []; +} + +for (const [key, value] of Object.entries(flatTokens)) { + // Primitive colour tokens: ColorBrand500 → brand.500 + const colorMatch = key.match(/^Color(Brand|Sage|Neutral|Red|Amber|Green|Blue)(\d+)$/); + if (colorMatch) { + const group = colorMatch[1].toLowerCase(); + const shade = colorMatch[2]; + if (!twColors[group]) twColors[group] = {}; + twColors[group][shade] = value; + continue; + } + // Single-value colors: ColorWhite, ColorBlack + if (key === 'ColorWhite') { twColors['white'] = value; continue; } + if (key === 'ColorBlack') { twColors['black'] = value; continue; } + + // Primitive spacing: Spacing1, Spacing0-5, Spacing16, etc. + const spacingMatch = key.match(/^Spacing(\d+)$/); + if (spacingMatch) { + twSpacing[spacingMatch[1]] = value; + continue; + } + if (key === 'Spacing05') { twSpacing['0.5'] = value; continue; } + + // Primitive border radius: BorderRadiusSm, BorderRadiusMd, etc. + const radiusMatch = key.match(/^BorderRadius(\w+)$/); + if (radiusMatch) { + twBorderRadius[radiusMatch[1].toLowerCase()] = value; + continue; + } + + // Font family: FontFamilyBody, FontFamilyDisplay, FontFamilyMono + const fontFamilyMatch = key.match(/^FontFamily(\w+)$/); + if (fontFamilyMatch) { + twFontFamily[fontFamilyMatch[1].toLowerCase()] = value; + continue; + } + + // Primitive font size: FontSize2xs, FontSizeBase, etc. (skip mobile/display) + const fontSizeMatch = key.match(/^FontSize(2xs|Xs|Sm|Base|Md|Lg|Xl|2xl|3xl|4xl)$/i); + if (fontSizeMatch) { + twFontSize[fontSizeMatch[1].toLowerCase()] = value; + continue; + } + + // Font weight: FontWeightRegular, FontWeightMedium, etc. + const fontWeightMatch = key.match(/^FontWeight(\w+)$/); + if (fontWeightMatch) { + twFontWeight[fontWeightMatch[1].toLowerCase()] = value; + continue; + } + + // Line height: LineHeightTight, LineHeightNormal, etc. + const lineHeightMatch = key.match(/^LineHeight(\w+)$/); + if (lineHeightMatch) { + twLineHeight[lineHeightMatch[1].toLowerCase()] = value; + continue; + } + + // Letter spacing: LetterSpacingTight, LetterSpacingNormal, etc. + const letterSpacingMatch = key.match(/^LetterSpacing(\w+)$/); + if (letterSpacingMatch) { + twLetterSpacing[letterSpacingMatch[1].toLowerCase()] = value; + continue; + } +} + +const tailwindTokens = { + colors: twColors, + spacing: twSpacing, + borderRadius: twBorderRadius, + fontFamily: twFontFamily, + fontSize: twFontSize, + fontWeight: twFontWeight, + lineHeight: twLineHeight, + letterSpacing: twLetterSpacing, +}; + +const twOutput = `/**\n * Auto-generated Tailwind tokens from Style Dictionary.\n * Do not edit directly — edit tokens/*.json and run npm run build:tokens.\n */\nmodule.exports = ${JSON.stringify(tailwindTokens, null, 2)};\n`; + +writeFileSync('src/theme/generated/tailwind-tokens.js', twOutput); +console.log(`✓ Generated Tailwind token module`); diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..91fd01a --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,198 @@ +/** @type {import('tailwindcss').Config} */ +const defaultTheme = require('tailwindcss/defaultTheme'); +const plugin = require('tailwindcss/plugin'); + +// Design system tokens generated from Style Dictionary +const dsTokens = require('./src/theme/generated/tailwind-tokens.js'); + +// ─── Split-background plugin (from production) ───────────────────────────── + +function createSplitBgPlugin() { + const result = {}; + result['.split-bg'] = { + background: + 'linear-gradient(90deg, var(--left-color, #000) 50%, var(--right-color, #fff) 50%)', + '--left-color': '#000', + '--right-color': '#fff', + }; + // Use design system palettes for split-bg utilities + const palettes = dsTokens.colors; + Object.entries(palettes).forEach(([colorName, shades]) => { + if (typeof shades !== 'object') return; + Object.entries(shades).forEach(([shade, value]) => { + result[`.split-left-${colorName}-${shade}`] = { '--left-color': value }; + result[`.split-right-${colorName}-${shade}`] = { '--right-color': value }; + }); + }); + return result; +} + +// ─── Config ───────────────────────────────────────────────────────────────── + +module.exports = { + content: [ + './src/**/*.{js,ts,jsx,tsx,mdx}', + './.storybook/**/*.{js,ts,jsx,tsx}', + ], + safelist: [{ pattern: /^line-clamp-.*/ }], + theme: { + // ── Breakpoints (match production + MUI v5/v7 defaults) ── + screens: { + xs: '0px', + sm: '600px', + md: '960px', + lg: '1280px', + xl: '1536px', + }, + + // ── List style types (from production) ── + listStyleType: { + none: 'none', + disc: 'disc', + circle: 'circle', + square: 'square', + decimal: 'decimal', + roman: 'lower-roman', + alpha: 'lower-alpha', + }, + + extend: { + // ── Colours ── + // Production's colour tokens (preserved for backward compatibility) + // plus design system semantic palettes + colors: { + // --- Production tokens (unchanged) --- + 'grey-1': '#f4f3ef', + 'grey-2': '#D7E1E2', + 'grey-3': '#b9c7c9', + 'grey-4': '#4c5459', + 'grey-5': '#4c5b6b', + 'grey-6': '#D9D9D9', + 'grey-7': '#EEEEEE', + 'warning-red': '#A41623', + 'color-1': '#B0610F', + 'color-1H': '#9e6f42', + 'color-2': '#DCC1A6', + 'color-2H': '#d1b79e', + 'color-3': '#BA834E', + 'color-4': '#D0A070', + 'color-5': '#F4F3EF', + 'color-6': '#51301B', + 'action-orange': '#F89E53', + 'success-green': '#76B041', + + // --- Design system palettes (from tokens) --- + ...dsTokens.colors, + }, + + // ── Font families ── + fontFamily: { + // Production names + sans: ['Montserrat', ...defaultTheme.fontFamily.sans], + arial: ['Arial', 'sans-serif'], + notoSerifSC: ['"Noto Serif SC"', 'serif'], + // Design system aliases + display: dsTokens.fontFamily.display?.split(',').map((f) => f.trim()) || ['"Noto Serif SC"', 'serif'], + mono: dsTokens.fontFamily.mono?.split(',').map((f) => f.trim()) || defaultTheme.fontFamily.mono, + }, + + // ── Spacing (production custom values + design system scale) ── + space: { + 8.5: '30px', + }, + padding: { + 1.25: '5px', + 3.1: '12px', + 3.15: '12.5px', + 3.5: '14px', + 3.75: '15px', + 7.5: '30px', + }, + margin: { + 1.25: '5px', + 3.1: '12px', + 3.15: '12.5px', + 3.75: '15px', + 7.5: '30px', + }, + + // ── Border width (from production) ── + borderWidth: { + 3: '3px', + 1.5: '1.5px', + 10: '10px', + }, + + // ── Aspect ratio (from production) ── + aspectRatio: { + '16/9': '16 / 9', + '4/3': '3 / 2', + }, + + // ── Heights (from production) ── + height: { + 18: '4.5rem', + 22: '5.5rem', + }, + minHeight: { + input: 'calc(30px + 1.5rem)', + }, + + // ── Box shadows (from production) ── + boxShadow: { + 'br-md': + '8px 0 15px -4px rgba(0, 0, 0, 0.15), 0 8px 15px -4px rgba(0, 0, 0, 0.15)', + badge: '0 0 5px 4px #f4f3ef', + 'br-sm': '0 4px 6px rgba(0,0,0,0.07)', + }, + + // ── Font sizes ── + // Production custom sizes + fontSize: { + xxs: '10px', + '1.5xl': '22px', + '2.15xl': '25px', + '2.3xl': '26px', + '3.3xl': '32px', + // Design system sizes (where they add values not in Tailwind defaults) + '2xs': dsTokens.fontSize['2xs'], + }, + + // ── Gap (from production) ── + gap: { + 3.75: '15px', + 7.5: '30px', + }, + + // ── Border radius ── + borderRadius: { + // Production + '1.5xl': '15px', + // Design system + ...dsTokens.borderRadius, + }, + + // ── Animation (from production) ── + animation: { + logoScroll: 'logoScroll 25s linear infinite', + }, + keyframes: { + logoScroll: { + '0%': { transform: 'translateX(0)' }, + '100%': { transform: 'translateX(-50%)' }, + }, + }, + + // ── Line height (design system) ── + lineHeight: dsTokens.lineHeight, + + // ── Letter spacing (design system) ── + letterSpacing: dsTokens.letterSpacing, + }, + }, + plugins: [ + plugin(function ({ addUtilities }) { + addUtilities(createSplitBgPlugin(), ['responsive']); + }), + ], +};