diff --git a/next/components/swatch/swatch.tsx b/next/components/swatch/swatch.tsx
new file mode 100644
index 000000000..c9bdb945a
--- /dev/null
+++ b/next/components/swatch/swatch.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+
+export default function Swatch({ value }: { value: string }): JSX.Element | null {
+ const declaration = value;
+ const styles = 'w1 h1 ml1 ml2 dib relative top-3';
+
+ if (declaration.includes('#')) {
+ const hex = declaration.match(/#[a-zA-Z0-9]+/);
+ return ;
+ }
+
+ return null;
+}
diff --git a/next/components/thumbprint-atomic/get-classes/get-classes.tsx b/next/components/thumbprint-atomic/get-classes/get-classes.tsx
new file mode 100644
index 000000000..64d4dfead
--- /dev/null
+++ b/next/components/thumbprint-atomic/get-classes/get-classes.tsx
@@ -0,0 +1,28 @@
+export interface CSSClass {
+ media?: string;
+ selectors: string[];
+ declarations: string[];
+}
+
+export interface File {
+ file: string;
+ classes: CSSClass[];
+}
+
+/**
+ * Given the `props` and a `fileName` like `aspect-ratio`, return the array of classes that
+ * correspond to that file. This function exists to simplify the Thumbprint Atomic MDX.
+ */
+const getClasses = (data: File[], fileName: string): File => {
+ const d = data.find(i => {
+ return i.file.startsWith(fileName);
+ });
+
+ if (!d) {
+ throw new Error(`getClasses failed on: ${fileName}`);
+ }
+
+ return d;
+};
+
+export default getClasses;
diff --git a/next/components/thumbprint-atomic/table/table.tsx b/next/components/thumbprint-atomic/table/table.tsx
new file mode 100644
index 000000000..694f48d78
--- /dev/null
+++ b/next/components/thumbprint-atomic/table/table.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { InlineCode } from '../../mdx/mdx';
+import Swatch from '../../swatch/swatch';
+
+const cleanSelector = (selector: string): string =>
+ selector
+ .replace('.', '')
+ .replace(':hover', '')
+ .replace(/hover.*:focus/, '')
+ .replace(/.*> \*/, '');
+
+export default function AtomicTable({
+ atomicClasses,
+}: {
+ atomicClasses: {
+ selectors: string[];
+ declarations: string[];
+ }[];
+}): JSX.Element {
+ const atomicClassesHackless = atomicClasses.filter(
+ // There are `shadow-*, _:-ms-lang(x)` hack selectors in the Atomic source code that
+ // provide darker box-shadows for IE/Edge. This filters out those selectors to prevent them
+ // from rendering in these docs.
+ item => !item.selectors.includes('_:-ms-lang(x)'),
+ );
+ return (
+
+
+ {atomicClassesHackless.map(classItem => (
+
+
+
+
+ {classItem.selectors.map(v => (
+
+
+
+ {cleanSelector(v)}
+
+ |
+
+ ))}
+
+
+ |
+
+
+
+ {classItem.declarations.map(v => (
+
+
+ {v}
+
+ |
+
+ ))}
+
+
+ |
+
+ ))}
+
+
+ );
+}
diff --git a/next/package.json b/next/package.json
index d066b68e4..660999be5 100644
--- a/next/package.json
+++ b/next/package.json
@@ -17,6 +17,7 @@
"@types/react": "18.0.26",
"@types/react-dom": "18.0.10",
"clickable-box": "^1.1.10",
+ "gonzales-pe": "^4.3.0",
"mousetrap": "^1.6.5",
"next": "13.1.1",
"prism-react-renderer": "0.1.7",
diff --git a/next/pages/atomic/index.tsx b/next/pages/atomic/index.tsx
new file mode 100644
index 000000000..64edbdc01
--- /dev/null
+++ b/next/pages/atomic/index.tsx
@@ -0,0 +1,246 @@
+import React from 'react';
+import fs from 'node:fs';
+import sass, { LegacyImporter } from 'sass';
+import nodeSassImporter from 'node-sass-tilde-importer';
+import gonzales from 'gonzales-pe';
+import prettier from 'prettier';
+import { GetStaticProps } from 'next';
+import Wrap from '../../components/wrap/wrap';
+import PageHeader from '../../components/page-header/page-header';
+import { H2, H3, UL, LI, InlineCode, P } from '../../components/mdx/mdx';
+import Table from '../../components/thumbprint-atomic/table/table';
+import getClasses, {
+ CSSClass,
+ File,
+} from '../../components/thumbprint-atomic/get-classes/get-classes';
+import CodeBlock from '../../components/mdx/code-block/code-block';
+import Layout from '../../components/layout/layout';
+import getLayoutProps, { LayoutProps } from '../../utils/get-layout-props';
+
+interface AtomicProps {
+ files: File[];
+ layoutProps: LayoutProps;
+}
+
+export default function Atomic({ files, layoutProps }: AtomicProps): React.ReactNode {
+ return (
+
+
+
+
+ Aspect Ratio
+
+
+ -
+ Available ratios are 16:9,{' '}
+ 10:13, 8:5,{' '}
+ 7:3, and 1:1.
+
+ -
+ Used primarily to lock elements with background images in into a desired
+ proportion.
+
+ -
+ Also for fluid media embedded from third party sites like YouTube, Vimeo,
+ etc.
+
+
+
+
+
+ Block-level elements
+
+ {``}
+
+
+ Don’t use any additional CSS on the element that changes{' '}
+ height or padding.
+
+
+ Video embeds and iframes
+
+
+ {`
+
+
`}
+
+
+
+ When using aspect-ratio-object be sure the embedded
+ content does not have conflicting height or{' '}
+ width values.
+
+
+
+
+
+ );
+}
+
+type Node = {
+ type: string;
+ content: Node[] | string;
+ syntax: 'css';
+ start: { line: number; column: number };
+ end: { line: number; column: number };
+ traverseByType: (type: string, arg1: unknown) => void;
+ traverse: (arg0: unknown) => void;
+ first: (arg0: string) => Node;
+ is: (type: string) => boolean;
+};
+
+/**
+ * Ensures that the `atrule` node is for a media query.
+ */
+const isAtRuleAMediaQuery = (atruleNode: Node): boolean =>
+ atruleNode.first('atkeyword').first('ident').content === 'media';
+
+/**
+ * Returns an array of rules such as `[ 'background-color: red !important;' ]`.
+ */
+const getBlockContent = (blockNode: Node): string[] => {
+ const declarations: string[] = [];
+
+ blockNode.traverseByType('declaration', (n: Node) => {
+ // Run through prettier to unminify the source CSS.
+ // https://github.com/tonyganch/gonzales-pe/blob/dev/docs/node-types.md#declaration
+ declarations.push(prettier.format(n.toString(), { parser: 'css' }).replace('\n', ''));
+ });
+
+ return declarations;
+};
+
+const getRulesetContent = (rulesetNode: Node): CSSClass => {
+ const data: { selectors: string[]; declarations: string[] } = {
+ selectors: [],
+ declarations: [],
+ };
+
+ // A ruleset contains a class (`.foo`) and `block` contains the rules.
+ rulesetNode.traverse((n: Node) => {
+ switch (n.type) {
+ case 'selector':
+ // Regex changes `.s\:bg-right` to `.s:bg-right`.
+ // https://github.com/tonyganch/gonzales-pe/blob/dev/docs/node-types.md#class
+ data.selectors.push(n.toString().replace(/\\/g, ''));
+ break;
+ case 'block':
+ // https://github.com/tonyganch/gonzales-pe/blob/dev/docs/node-types.md#block
+ data.declarations = getBlockContent(n);
+ break;
+ default:
+ break;
+ }
+ });
+
+ return data;
+};
+
+/**
+ * `atrule` nodes are used for media queries (among other things). There are usually `ruleset`
+ * nodes within them.
+ */
+const getAtruleContent = (atruleNode: Node): CSSClass[] => {
+ const classesWithinMediaQueries: CSSClass[] = [];
+
+ if (isAtRuleAMediaQuery(atruleNode)) {
+ atruleNode.traverse((node: Node) => {
+ if (node.is('ruleset')) {
+ classesWithinMediaQueries.push({
+ media: atruleNode.first('parentheses').toString(),
+ // Since this is a ruleset, we can reuse the function that is run for nodes
+ // that aren't within media queries.
+ ...getRulesetContent(node),
+ });
+ }
+ });
+ }
+
+ return classesWithinMediaQueries;
+};
+
+/**
+ * Parses a CSS file into an array of classes.
+ */
+const parseAST = (css: string): CSSClass[] => {
+ const classes: Array = [];
+ const parseTree = gonzales.parse(css, { syntax: 'css' });
+
+ // Starting at the top node, we only care about `ruleset` and `atrule` nodes. These will give
+ // us a list of classes as well as information about media queries.
+ parseTree.forEach((node: Node) => {
+ if (node.is('ruleset')) {
+ // https://github.com/tonyganch/gonzales-pe/blob/dev/docs/node-types.md#ruleset
+ classes.push(getRulesetContent(node));
+ } else if (node.is('atrule')) {
+ // https://github.com/tonyganch/gonzales-pe/blob/dev/docs/node-types.md#atrule
+ classes.push(getAtruleContent(node));
+ }
+ });
+
+ return classes.flat();
+};
+
+export const getStaticProps: GetStaticProps = async () => {
+ const atomicSassFilesPath = '../packages/thumbprint-atomic/src/packages';
+ const directoryPath = atomicSassFilesPath;
+ const files = fs.readdirSync(directoryPath);
+
+ const data = files.map(file => {
+ const { css } = sass.renderSync({
+ file: `${atomicSassFilesPath}/${file}`,
+ // The only difference is in the return type, which doesn't make a difference in practice,
+ // so we're safe to typecast here.
+ importer: nodeSassImporter as LegacyImporter<'sync'>,
+ });
+
+ return {
+ file,
+ classes: parseAST(css.toString()),
+ };
+ });
+
+ return {
+ props: {
+ layoutProps: getLayoutProps(),
+ files: data,
+ },
+ };
+};
diff --git a/next/pages/atomic/usage.mdx b/next/pages/atomic/usage.mdx
new file mode 100644
index 000000000..006ff8d58
--- /dev/null
+++ b/next/pages/atomic/usage.mdx
@@ -0,0 +1,135 @@
+import MDX from '../../components/mdx/mdx';
+export { getStaticProps } from '../../components/mdx/mdx';
+export default props => ;
+
+export const metadata = {
+ title: 'Using Atomic',
+ description: 'Getting started building UIs.',
+};
+
+Atomic CSS leans on small, tersely named, single-purpose classes that are combined in the HTML to build up UIs. This approach can speed development time, reduce complexity, and increase consistency by limiting, and in some cases eliminating, the need to author new CSS.
+
+## Getting Started
+
+If you’re unfamiliar with Atomic CSS here are a few resources.
+
+- [Let’s Define Exactly What Atomic CSS is](https://css-tricks.com/lets-define-exactly-atomic-css/)
+- [CSS Utility Classes and “Separation of Concerns”](https://adamwathan.me/css-utility-classes-and-separation-of-concerns/)
+- [How I Learned to Stop Worrying and Love the Atomic Class](https://medium.com/buzzfeed-design/how-i-learned-to-stop-worrying-and-love-the-atomic-class-98d6ccc45781)
+
+## Syntax
+
+The list of classes in Thumbprint Atomic are based primarily on [Tachyons](https://tachyons.io), easily the most popular of the Atomic libraries, with some syntax changes.
+
+### Scale
+
+Classes that have no space between the letters and numbers reference a scale that grows exponentially. Our [spacing units](https://thumbprint.design/guide/product/layout/spacers/) go from `4px`, `8px`, `16px` and so on and is used by the margin and padding classes. Note that different concepts use different scales.
+
+- The class `pa4` sets padding on all sides of an element using the fourth value in the spacing scale, which is `24px`.
+- The class `w1` sets the width of an element using the first value in the width scale, which is `16px`.
+
+### Literal
+
+Classes where a dash separates the letter and number reference literal values.
+
+- The class `bw-2` sets the width of a border to `2px`.
+- The class `top-4` sets the value of the `top` property to `4px`.
+
+### Responsive
+
+Nearly all classes have variations that will apply styling at breakpoints. These classes are prefixed with `s_`, `m_`, and `l_`. In the this example a different `padding-bottom` value is applied at each breakpoint.
+
+```html shouldRender=false
+…
+```
+
+- By default `pb2`.
+- Above the small breakpoint `pb3`.
+- Above the medium breakpoint `pb4`.
+- Above the large breakpoint `pb5`.
+
+### `!important`
+
+For backwards compatibility with previous utility classes and to ensure that the Atomic class takes precedence in the CSS, every declaration includes the `!important` rule.
+
+## Usage
+
+Thumbprint Atomic classes are available to all React and Twig pages on thumbtack.com.
+
+### React
+
+Atomic is included in `Layout.jsx` which means all React pages will include Atomic classes by default. The recommended usage is to contain the classes in the JSX by using the Atomic classes directly, or as variables, and write any custom CSS not covered by Atomic classes in a Sass file.
+
+#### CSS Modules
+
+Do not add Atomic classes using the [composes](https://github.com/css-modules/css-modules#composition) functionality that’s available in CSS Modules. Due to the specificity and the built-in responsiveness of Atomic classes, unexpected behavior can result.
+
+#### JSX
+
+```js shouldRender=false
+…
+```
+
+#### Sass
+
+This CSS is not included in our current Atomic library. Custom Sass must be written.
+
+```scss
+.card {
+ box-shadow: 0 0 2px 3px rgba(0, 0, 0, 0.2);
+
+ @include tp-respond-above($tp-breakpoint__large) {
+ min-height: 300px;
+ }
+}
+```
+
+### Legacy (Twig/Angular)
+
+Atomic is included on every page using the `legacy-angular-global-harness`. Classes should be used in the HTML similar to the approach above: use Atomic classes wherever possible and add a custom class when needed.
+
+#### HTML
+
+```html shouldRender=false
+…
+```
+
+#### Sass
+
+As with the previous Sass example above, since this CSS is not included in our current Atomic library, custom Sass must be written.
+
+```scss
+.card {
+ box-shadow: 0 0 2px 3px rgba(0, 0, 0, 0.2);
+
+ @include tp-respond-above($tp-breakpoint__large) {
+ min-height: 300px;
+ }
+}
+```
+
+## Adding new Atomic classes
+
+We occasionally get requests to add new classes to the Atomic library. In an effort to balance the library’s completeness with its bundle size, we’ve adopted the approach that there should be at least 10 instances of a property in Thumbtack's website codebase before we add it to Atomic.
+
+If the Atomic class you’d like is not available please write the custom CSS in your Sass file as shown the [usage](#section-usage) examples above. You can can also open an [issue](https://github.com/thumbtack/thumbprint/issues/new) if you would like us to consider adding it.
+
+## Limitations
+
+Although Atomic can significantly reduce the amount of CSS you write it won’t cover every use case. At times you’ll have to use other approaches.
+
+- **Custom values** Layouts will sometimes require non-standardized positioning, heights, or widths that aren’t available in the Atomic library. Hardcode them into your CSS with a comment.
+- **`last-child` and `first-child` selectors** Atomic isn’t well-suited to handle pseudo classes. For example, if you need `margin-bottom` on all objects but the last one, either:
+ 1. Use conditional logic in your loop to apply an Atomic margin-bottom class to all but the last item.
+ 2. Use a custom class and manage the margin in the CSS. You can also use the `:not()` selector for a one-liner that skips the last-child:
+ ```scss
+ .item:not(:last-child) {
+ margin-bottom: $tp-space__3;
+ }
+ ```
+
+## Refactoring React
+
+- As a rule of thumb, if more than 50% of a React component is being updated the developer should convert the Sass styles to Atomic classes.
+- When adding Atomic to elements in legacy components, you do not need to convert the entire component, but at minimum convert all the CSS on the element you are updating. CRs should be blocked if this step has not been taken (within reason).
+- Similar to our migration from Twig to React, refactoring existing React pages to use Atomic will require the judgment of the developer.
diff --git a/next/utils/get-layout-props.ts b/next/utils/get-layout-props.ts
index 04cb6d1da..9b8396e16 100644
--- a/next/utils/get-layout-props.ts
+++ b/next/utils/get-layout-props.ts
@@ -50,6 +50,50 @@ export default function getLayoutProps(): LayoutProps {
],
],
},
+ {
+ title: 'Atomic',
+ href: '/atomic',
+ groups: [
+ [{ title: 'Usage', href: '/atomic/usage' }],
+ [
+ { title: 'Aspect Ratio', href: '/atomic#section-aspect-ratio' },
+ {
+ title: 'Background Position',
+ href: '/atomic#section-background-position',
+ },
+ { title: 'Background Size', href: '/atomic#section-background-size' },
+ { title: 'Border', href: '/atomic#section-border' },
+ { title: 'Border Color', href: '/atomic#section-border-color' },
+ { title: 'Border Radius', href: '/atomic#section-border-radius' },
+ { title: 'Border Style', href: '/atomic#section-border-style' },
+ { title: 'Border Width', href: '/atomic#section-border-width' },
+ { title: 'Box Shadow', href: '/atomic#section-box-shadow' },
+ { title: 'Color', href: '/atomic#section-color' },
+ { title: 'Coordinates', href: '/atomic#section-coordinates' },
+ { title: 'Cursor', href: '/atomic#section-cursor' },
+ { title: 'Display', href: '/atomic#section-display' },
+ { title: 'Flexbox', href: '/atomic#section-flexbox' },
+ { title: 'Font Weight', href: '/atomic#section-font-weight' },
+ { title: 'Grid', href: '/atomic#section-grid' },
+ { title: 'Height', href: '/atomic#section-height' },
+ { title: 'Margin', href: '/atomic#section-margin' },
+ { title: 'Max Width', href: '/atomic#section-max-width' },
+ { title: 'Overflow', href: '/atomic#section-overflow' },
+ { title: 'Padding', href: '/atomic#section-padding' },
+ { title: 'Position', href: '/atomic#section-position' },
+ { title: 'Text Align', href: '/atomic#section-text-align' },
+ { title: 'Text Decoration', href: '/atomic#section-text-decoration' },
+ { title: 'Text Transform', href: '/atomic#section-text-transform' },
+ { title: 'Truncate', href: '/atomic#section-truncate' },
+ { title: 'Vertical Align', href: '/atomic#section-vertical-align' },
+ { title: 'Visually Hidden', href: '/atomic#section-visually-hidden' },
+ { title: 'White Space', href: '/atomic#section-white-space' },
+ { title: 'Width', href: '/atomic#section-width' },
+ { title: 'Word Break', href: '/atomic#section-word-break' },
+ { title: 'Z-Index', href: '/atomic#section-z-index' },
+ ],
+ ],
+ },
{
title: 'Tokens',
href: '/tokens/scss',
diff --git a/typings/index.d.ts b/typings/index.d.ts
index 8115bff9d..77b95ff32 100644
--- a/typings/index.d.ts
+++ b/typings/index.d.ts
@@ -46,6 +46,8 @@ declare module 'react-outside-click-handler';
declare module 'mousetrap';
+declare module 'gonzales-pe';
+
// An SCSS module returns a map from string => string when imported.
declare module '*.module.scss' {
const classes: { [key: string]: string };
diff --git a/yarn.lock b/yarn.lock
index 387376fed..7a5ea3260 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -17348,6 +17348,17 @@ __metadata:
languageName: node
linkType: hard
+"gonzales-pe@npm:^4.3.0":
+ version: 4.3.0
+ resolution: "gonzales-pe@npm:4.3.0"
+ dependencies:
+ minimist: ^1.2.5
+ bin:
+ gonzales: bin/gonzales.js
+ checksum: 49d60fc49ad35639e5d55923c1516d3ec2e4de5e6e5913ec3458a479b66623e54a060d568295349b0bb9f96ee970c473ff984d4b82a5cfeaf736c55f0d6dc3b7
+ languageName: node
+ linkType: hard
+
"gopd@npm:^1.0.1":
version: 1.0.1
resolution: "gopd@npm:1.0.1"
@@ -23746,6 +23757,7 @@ is-whitespace@latest:
"@types/react": 18.0.26
"@types/react-dom": 18.0.10
clickable-box: ^1.1.10
+ gonzales-pe: ^4.3.0
mousetrap: ^1.6.5
next: 13.1.1
prism-react-renderer: 0.1.7