Share
Embed
The dream that turned into nightmare
Radoslav Stankov
November 15, 2024
Transcript
100 slides
-
The dream that turned into nightmare ! ..and how we
wake up Radoslav Stankov -
"Dream" is short for "the dream to not write custom
CSS". Let's hope v2 is not named "Nightmare" ! Goals - Have consistent UI on the website - Make it easier to build new pages - Implement Product Hunt v6 design - Fully type safe What - Foundational utilities - Core UI components -
"Dream" is short for "the dream to not write custom
CSS". Let's hope v2 is not named "Nightmare" ! Goals - Have consistent UI on the website - Make it easier to build new pages - Implement Product Hunt v6 design - Fully type safe What - Foundational utilities - Core UI components ! -
Core Design System - 4px grid - font size -
font weights - color tokens - layouts -
import { Layout } from 'ph/components/dream' <Layout header={Layout.Header}> <Layout.Main />
<Layout.Aside /> </Layout> -
import { Button } from 'ph/components/dream' <Button.Solid label="text" icon={TwitterIcon} />
<Button.Outline label="text" /> <Button.Text label="text" /> <Button.Icon icon={TwitterIcon} /> -
import { Button } from 'ph/components/dream' <Button.Solid to={paths.post(post)} /> <Button.Solid
onClick={fn} /> <Button.Solid mutation={GRAPHQL_MUTATION} /> <Button.Solid confirm="Are you sure" /> <Button.Solid requireLogin={true} /> -
import { Form } from 'ph/components/dream' <Form.Mutation mutation={GRAPHQL_MUTATION} onSubmit={onSubmit}> <Form.Label
label="First name" flex={1}> <Form.Input name="first_name" placeholder="Placeholder" /> </Form.Label> <Form.Label label="Last name" flex={1}> <Form.Input name="last_name" placeholder="Placeholder" /> </Form.Label> <Form.Submit /> <Form.Status /> </Form.Mutation> -
import { Text } from '~/components/dream' <Text as="h1" /> <Text.Title>Title</Text.Title>
<Text.Subtile>Subtile<Text./Subtile> <Text.Bold>Bold</Text.Bold> -
<Text>{content}</Text> <Text fontSize={16}>{content}</Text> <Text fontSize={16} p={1}>{content}</Text> <Text fontSize={16} p={{ mobile:
1, tablet: 2, desktop: 3}}>{content}</Text> <Text fontSize={{ mobile: 12, tablet: 14, desktop: 16}} p={{ mobile: 1, tablet: 2, <div className="fontSize-14">{content}</div> <div className="fontSize-16">{content}</div> <div className="fontSize-16 p-1">{content}</div> <div className="fontSize-16 p-mobile-1 p-table-2 p-desktop-3">{content}</div> <div className="fontSize-mobile-12 fontSize-mobile-14 fontSize-deskopt-16 p-mobile -
<Text>{content}</Text> <Text fontSize={16}>{content}</Text> <Text fontSize={16} p={1}>{content}</Text> <Text fontSize={16} p={{ mobile:
1, tablet: 2, desktop: 3}}>{content}</Text> <Text fontSize={{ mobile: 12, tablet: 14, desktop: 16}} p={{ mobile: 1, tablet: 2, <div className="fontSize-14">{content}</div> <div className="fontSize-16">{content}</div> <div className="fontSize-16 p-1">{content}</div> <div className="fontSize-16 p-mobile-1 p-table-2 p-desktop-3">{content}</div> <div className="fontSize-mobile-12 fontSize-mobile-14 fontSize-deskopt-16 p-mobile -
<Text>{content}</Text> <Text fontSize={16}>{content}</Text> <Text fontSize={16} p={1}>{content}</Text> <Text fontSize={16} p={{ mobile:
1, tablet: 2, desktop: 3}}>{content}</Text> <Text fontSize={{ mobile: 12, tablet: 14, desktop: 16}} p={{ mobile: 1, tablet: 2, <div className="fontSize-14">{content}</div> <div className="fontSize-16">{content}</div> <div className="fontSize-16 p-1">{content}</div> <div className="fontSize-16 p-mobile-1 p-table-2 p-desktop-3">{content}</div> <div className="fontSize-mobile-12 fontSize-mobile-14 fontSize-deskopt-16 p-mobile -
<Text>{content}</Text> <Text fontSize={16}>{content}</Text> <Text fontSize={16} p={1}>{content}</Text> <Text fontSize={16} p={{ mobile:
1, tablet: 2, desktop: 3}}>{content}</Text> <Text fontSize={{ mobile: 12, tablet: 14, desktop: 16}} p={{ mobile: 1, tablet: 2, <div className="fontSize-14">{content}</div> <div className="fontSize-16">{content}</div> <div className="fontSize-16 p-1">{content}</div> <div className="fontSize-16 p-mobile-1 p-table-2 p-desktop-3">{content}</div> <div className="fontSize-mobile-12 fontSize-mobile-14 fontSize-deskopt-16 p-mobile -
<Text>{content}</Text> <Text fontSize={16}>{content}</Text> <Text fontSize={16} p={1}>{content}</Text> <Text fontSize={16} p={{ mobile:
1, tablet: 2, desktop: 3}}>{content}</Text> <Text fontSize={{ mobile: 12, tablet: 14, desktop: 16}} p={{ mobile: 1, tablet: 2, <div className="fontSize-14">{content}</div> <div className="fontSize-16">{content}</div> <div className="fontSize-16 p-1">{content}</div> <div className="fontSize-16 p-mobile-1 p-table-2 p-desktop-3">{content}</div> <div className="fontSize-mobile-12 fontSize-mobile-14 fontSize-deskopt-16 p-mobile -
interface IProps extends ITextCSSProps { children: React.ReactNode; className?: string; as?:
'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'strong' | 'p' | 'div' | 'i' | 'legend' | 'details' | 'footer' | 'summary'; id?: string; onClick?: React.MouseEventHandler; format?: boolean; noOfLines?: INoOfLines; center?: boolean; target?: '_blank' | '_self'; 'data-test'?: string; 'aria-label'?: string; ellipsis?: boolean; } export default function Text({ children, className, as, id, color = 'darker-grey', fontSize = 16, fontWeight = 400, format = false, center = false, noOfLines, 'data-test': dataTest, onClick, ellipsis, ...otherProps }: IProps) { const finalClassName = classNames( dreamCSSClasses({ ...otherProps, color, fontSize, fontWeight, noOfLines }), className, format && styles.format, center && styles.center, ellipsis && styles.ellipsis, ); const Component = as ?? 'div'; return ( <Component className={finalClassName} id={id} data-test={dataTest} onClick={onClick}> {children} </Component> ); } -
export function deprecatedDreamCSSClasses(props: IDreamCSS) { let classes: string = '';
const propNames = Object.keys(props); for (const propName of propNames) { const propValue = props[propName]; if (!ALLOWED_PROPS[propName]) { continue; } if (typeof propValue === 'object') { classes = classesForObject(classes, propName, propValue); } else { classes = classesForProps(classes, propName, propValue); } } return classes.trim(); } function classesForObject(classes: string, prop: string, props: any) { if (isNotSet(props.widescreen) && props.desktop) { props.widescreen = props.desktop; } -
function classesForObject(classes: string, prop: string, props: any) { if (isNotSet(props.widescreen)
&& props.desktop) { props.widescreen = props.desktop; } if (isNotSet(props.tablet) && props.mobile) { props.tablet = props.mobile; } const modifiers = Object.keys(props); for (const modifier of modifiers) { const propValue = props[modifier]; if (modifier === 'l') { classes = classesForProps(classes, prop, propValue); } else { classes = classesForProps(classes, `${prop}-${modifier}`, propValue); } } return classes; } function classesForProps(classes: string, propName: string, propValue: any) { if (typeof propValue === 'boolean' && propValue) { classes += `${propName}-default `; } else { -
const modifiers = Object.keys(props); for (const modifier of modifiers) {
const propValue = props[modifier]; if (modifier === 'l') { classes = classesForProps(classes, prop, propValue); } else { classes = classesForProps(classes, `${prop}-${modifier}`, propValue); } } return classes; } function classesForProps(classes: string, propName: string, propValue: any) { if (typeof propValue === 'boolean' && propValue) { classes += `${propName}-default `; } else { classes += `${propName}-${propValue} `; } return classes; } function isNotSet(value) { return typeof value === 'undefined' || value === null; } -
! Problem #1 - CSS bundle size All possible class
names must be pre-generated and included in the CSS bundle because they are generated dynamically. -
! Problem #1 - CSS bundle size gap: 14 sizes
* 5 variants = 46 margin: 7 props * 14 sizes * 5 variants = 350 padding: 7 props * 14 sizes * 5 variants = 350 colors: 2 props * 12 colors * 5 variants = 120 ... ... total: ~2000 class names ! -
! Problem #2 - too many loops dreamCSSClasses was called
for many components to build their class names. This results in loops and variable generation, resulting in many GCs and slowing rendering. Especially the initial rendering. -
! Problem #2 - too many loops dreamCSSClasses was called
for many components to build their class names. This results in loops and variable generation, resulting in many GCs and slowing rendering. Especially the initial rendering. -
! Problem #2 - too many loops 2000+ calls to
dreamCSSClasses for single render -
! Problem #3 - too many props Core components like
Text and Flex, accepted subsets of dreamCSSClasses props, leading to overcomplicated code. -
interface IProps extends ITextCSSProps { children: React.ReactNode; className?: string; as?:
'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'strong' | 'p' | 'div' | 'i' | 'legend' | 'details' | 'footer' | 'summary'; id?: string; onClick?: React.MouseEventHandler; format?: boolean; noOfLines?: INoOfLines; center?: boolean; target?: '_blank' | '_self'; 'data-test'?: string; 'aria-label'?: string; ellipsis?: boolean; } export default function Text({ children, className, as, id, color = 'darker-grey', fontSize = 16, fontWeight = 400, format = false, center = false, noOfLines, 'data-test': dataTest, onClick, ellipsis, ...otherProps }: IProps) { const finalClassName = classNames( dreamCSSClasses({ ...otherProps, color, fontSize, fontWeight, noOfLines }), className, format && styles.format, center && styles.center, ellipsis && styles.ellipsis, ); const Component = as ?? 'div'; return ( <Component className={finalClassName} id={id} data-test={dataTest} onClick={onClick}> {children} </Component> ); } -
- Button and useOnClick(prop) - Form components - interface of
dream components was consistent - developers were able to build really fast ! The good -
- CSS bundle size - performance - spacing props -
very complicated core components - core components accepted a lot of props - still needed custom css for borders/shadows - a lot of nesting of core components like Text / Flex ! The bad -
- a lot of "as=" props complicating everything - Flex
component wasn't flexible enough - Text component was too complex - designers were constrained - dream was desktop first ! The ugly -
- a lot of "as=" props complicating everything - Flex
component wasn't flexible enough - Text component was too complex - designers were constrained - dream was desktop first ! The ugly pun intended ! -
Step 1: Unify Dream and Tailwind class names Step 2:
Replace dreamCSSClasses with "tw" helper Step 3: Inline "Flex" and "Text" Step 4: Switch to Tailwind Step 5: Replace "tw" with "eslint-plugin-tailwindcss" ⚔ Battle plan " -
Step 1: Unify Dream and Tailwind class names Step 2:
Replace dreamCSSClasses with "tw" helper Step 3: Inline "Flex" and "Text" Step 4: Switch to Tailwind Step 5: Replace "tw" with "eslint-plugin-tailwindcss" ⚔ Battle plan " -
<Text>{content}</Text> <Text fontSize={16}>{content}</Text> <Text fontSize={16} p={1}>{content}</Text> <Text fontSize={16} p={{ mobile:
1, tablet: 2, desktop: 3}}>{content}</Text> <Text fontSize={{ mobile: 12, tablet: 14, desktop: 16}} p={{ mobile: 1, tablet: 2, ! " -
<div className="fontSize-14">{content}</div> <div className="fontSize-16">{content}</div> <div className="fontSize-16 p-1">{content}</div> <div className="fontSize-16 p-mobile-1
p-table-2 p-desktop-3">{content}</div> <div className="fontSize-mobile-12 fontSize-tablet-14 fontSize-deskop-16 p-mobile- ! " -
<div className="text-sm">{content}</div> <div className="text-base">{content}</div> <div className="text-base p-1">{content}</div> <div className="text-base p-1
sm:p-2 md:p-3">{content}</div> <div className="text-xs sm:text-sm md:text-base p-1 sm:p-2 md:p-3">{content}</div> ! " -
Step 1: Unify Dream and Tailwind class names Step 2:
Replace dreamCSSClasses with "tw" helper Step 3: Inline "Flex" and "Text" Step 4: Switch to Tailwind Step 5: Replace "tw" with "eslint-plugin-tailwindcss" ⚔ Battle plan " -
! " <div className={dreamCSSClasses({ px: { mobile: 1 }, pt:
{ mobile: 0, desktop: 6, tablet: 6 }, pb: { mobile: 7, desktop: 6, tablet: 6 }, })}> -
! " // NOTE(rstankov): List only what is being used
// - All properties can be found here - https://tailwindcss.com/docs/utility-first // - PurgeCSS text searches the app, so if a value is in this list it is included in bundle, however interpolation makes sure we don't include all colors type ITailwindClassNames = | 'antialiased' | 'border' | 'cursor-pointer' | `m${IDirection}-${IDistanceWithAuto}` | `w-${IDistanceWithFull}` // ... list all classNames used in the system type IColors = 'brand' | 'gray-50' | 'gray-400' | 'gray-900' | 'white'; type IDirection = 'x' | 'y' | 't' | 'r' | 'b' | 'l'; type IDistance = 0 | 1 | '1.5' | 2 | 4 | 5 | 6 | 8 | 12 | 16 | 40; type IDistanceWithFull = 'full' | IDistance | '3xl'; type IDistanceWithAuto = 'auto' | IDistance; type ISize = 'lg' | 'xl' | '4xl'; type IBreakpoints = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; // NOTE(rstankov): Check if strings contains only Tailwind classes // Explanation how this works: // - https://www.kirillvasiltsov.com/writing/type-check-tailwind-css/ export type ITailwind<S> = S extends `${infer Class} ${infer Rest}` ? Class extends ITailwindClassNames ? `${Class} ${ITailwind<Rest>}` : never : S extends `${infer Class}` ? Class extends ITailwindClassNames ? S : never : never; // NOTE(rstankov): Helper to verify class names listed match available Tailwind class names export default function tw<S>(classes: ITailwind<S>) { return classes; } -
import { Button, Text, dm } from 'ph/components/dream'; import {
Text, dm } from 'ph/components/dream'; import Button from 'ph/components/dream/Button'; jscodeshift -t transform.js components/*.tsx -
module.exports = function(fileInfo, api, options) { const j = api.jscodeshift;
const root = j(fileInfo.source); // Find import declarations from 'ph/components/dream' root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'ph/components/dream', }, }) .forEach(path => { let buttonImportSpecifier = path.value.specifiers.find( specifier => specifier.imported && specifier.imported.name === 'Button' ); // If Button is imported from the package, modify the import statement if (buttonImportSpecifier) { // Remove Button from the original import path.value.specifiers = path.value.specifiers.filter(specifier => specifier.imported.name !== 'Button'); // If after removing Button there are no specifiers left, remove the whole import statement if (path.value.specifiers.length === 0) { j(path).remove(); } // Create a new import declaration for Button const newImportDeclaration = j.importDeclaration( [j.importDefaultSpecifier(j.identifier('Button'))], j.literal('ph/components/dream/Button') ); // Insert the new import declaration after the original one j(path).insertAfter(newImportDeclaration); } }); return root.toSource({quote: 'single'}); }; -
- add starting sample - provide a lot of examples
- negative statement (like DON'T) don't work mostly - start new chat sessions often, LLMs are autoregressive - it will get you to 80% and will get stuck - stop using it when stuck ! Prompting Tips " -
Step 1: Unify Dream and Tailwind class names Step 2:
Replace dreamCSSClasses with "tw" helper Step 3: Inline "Flex" and "Text" Step 4: Switch to Tailwind Step 5: Replace "tw" with "eslint-plugin-tailwindcss" ⚔ Battle plan "