$30 off During Our Annual Pro Sale. View Details »

The dream that turned into nightmare

The dream that turned into nightmare

Radoslav Stankov

November 15, 2024
Tweet

More Decks by Radoslav Stankov

Other Decks in Technology

Transcript

  1. !

  2. "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
  3. "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 !
  4. Core Design System - 4px grid - font size -

    font weights - color tokens - layouts
  5. import { Button } from 'ph/components/dream' <Button.Solid label="text" icon={TwitterIcon} />

    <Button.Outline label="text" /> <Button.Text label="text" /> <Button.Icon icon={TwitterIcon} />
  6. 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} />
  7. 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>
  8. import { Text } from '~/components/dream' <Text as="h1" /> <Text.Title>Title</Text.Title>

    <Text.Subtile>Subtile<Text./Subtile> <Text.Bold>Bold</Text.Bold>
  9. <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
  10. <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
  11. <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
  12. <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
  13. <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
  14. 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> ); }
  15. 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; }
  16. 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 {
  17. 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; }
  18. ! Problem #1 - CSS bundle size All possible class

    names must be pre-generated and included in the CSS bundle because they are generated dynamically.
  19. ! 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 !
  20. ! 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.
  21. ! 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.
  22. ! Problem #2 - too many loops 2000+ calls to

    dreamCSSClasses for single render
  23. ! Problem #3 - too many props Core components like

    Text and Flex, accepted subsets of dreamCSSClasses props, leading to overcomplicated code.
  24. 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> ); }
  25. - Button and useOnClick(prop) - Form components - interface of

    dream components was consistent - developers were able to build really fast ! The good
  26. - 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
  27. - 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
  28. - 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 !
  29. 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 "
  30. 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 "
  31. <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, ! "
  32. <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- ! "
  33. <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> ! "
  34. 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 "
  35. ! " <div className={dreamCSSClasses({ px: { mobile: 1 }, pt:

    { mobile: 0, desktop: 6, tablet: 6 }, pb: { mobile: 7, desktop: 6, tablet: 6 }, })}>
  36. ! " // 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; }
  37. 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
  38. 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'}); };
  39. ! "

  40. - 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 "
  41. 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 "