/** * Hollie Tanner Design System: tokens * * Drop-in, dependency-free. Import the typed values directly, or generate * CSS custom properties with `toCssVariables()` / `cssVariablesStylesheet`. * * Source of truth: the values live here; the site's CSS mirrors them. * Usage rules: see ./building-with-the-design-system.md */ /* ------------------------------------------------------------------ */ /* Color */ /* ------------------------------------------------------------------ */ export const themes = { dark: { background: "#2a2d30", surface: "#323538", surfaceSecondary: "#3b3e42", /** Name, section headings, and titles. Pure white is headline-only. */ headline: "#ffffff", text: "#eceeee", /** The bio blurb: one step brighter than body text. */ lede: "#dcdfe0", /** Body copy, labels, tags. */ muted: "#c2c6c8", border: "rgba(255, 255, 255, 0.16)", borderStrong: "rgba(255, 255, 255, 0.3)", /** Matches the teal silhouette on the dark-theme portrait. */ accent: "#61d889", /** Teal is light enough to double as text in this theme. */ accentText: "#61d889", /** Text and icons placed on an accent fill. Never white. */ onAccent: "#141414", /** Theme toggle: previews the light theme's orange, softened for the dark band. */ toggle: "#f5946f", menuBand: "rgba(0, 0, 0, 0.15)", }, light: { background: "#f3f2ee", surface: "#faf9f6", surfaceSecondary: "#e9e7e2", headline: "#171717", text: "#171717", lede: "#55524e", muted: "#55524e", border: "rgba(23, 23, 23, 0.14)", borderStrong: "rgba(23, 23, 23, 0.28)", /** Matches the orange silhouette on the light-theme portrait. Fills only. */ accent: "#fc6400", /** Same hue, darkened to pass 4.5:1 on the background and the menu band. */ accentText: "#ad4500", onAccent: "#ffffff", toggle: "#55524e", menuBand: "rgba(190, 112, 62, 0.12)", }, } as const; export type ThemeName = keyof typeof themes; export type ColorToken = keyof (typeof themes)["dark"]; /* ------------------------------------------------------------------ */ /* Typography */ /* ------------------------------------------------------------------ */ export const fontFamilies = { /** The voice. The name, page titles, section headings, and headline figures only. */ display: '"Gloock", Georgia, serif', /** Titles of things: case studies, projects, books, the about lede. */ title: '"Cormorant Garamond", Georgia, serif', /** Reading and labeling. */ body: '"Inter", system-ui, sans-serif', } as const; /** Google Fonts request covering every weight the system uses. */ export const fontStylesheetUrl = "https://fonts.googleapis.com/css2?family=Gloock&family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400&family=Inter:wght@400;500;600&display=swap"; type TypeStyle = { family: keyof typeof fontFamilies; size: string; weight: number; lineHeight: number; letterSpacing?: string; textTransform?: "uppercase"; fontStyle?: "italic"; }; export const typeScale = { display: { family: "display", size: "clamp(3rem, 5vw, 4.25rem)", weight: 400, lineHeight: 1, letterSpacing: "-0.01em" }, /** Project and reference page titles. */ pageTitle: { family: "display", size: "clamp(2.6rem, 5vw, 4.25rem)", weight: 400, lineHeight: 1.02, letterSpacing: "-0.01em" }, /** Headline numbers in project results. */ metricFigure: { family: "display", size: "clamp(2rem, 3.2vw, 2.75rem)", weight: 400, lineHeight: 1.05 }, sectionHeading: { family: "display", size: "1.65rem", weight: 400, lineHeight: 1.15, letterSpacing: "-0.005em" }, sectionHeadingMobile: { family: "display", size: "1.5rem", weight: 400, lineHeight: 1.15, letterSpacing: "-0.005em" }, caseTitle: { family: "title", size: "1.5rem", weight: 600, lineHeight: 1.15 }, projectTitle: { family: "title", size: "1.2rem", weight: 600, lineHeight: 1.2 }, bookTitle: { family: "title", size: "1.02rem", weight: 500, lineHeight: 1.25 }, lede: { family: "title", size: "1.2rem", weight: 400, lineHeight: 1.4, fontStyle: "italic" }, intro: { family: "body", size: "clamp(1rem, 1.15vw, 1.1rem)", weight: 400, lineHeight: 1.6 }, body: { family: "body", size: "0.94rem", weight: 400, lineHeight: 1.7 }, /** Running text on project pages. */ longformBody: { family: "body", size: "1rem", weight: 400, lineHeight: 1.7 }, bodySmall: { family: "body", size: "0.9rem", weight: 400, lineHeight: 1.55 }, tags: { family: "body", size: "0.72rem", weight: 400, lineHeight: 1.4 }, label: { family: "body", size: "0.66rem", weight: 500, lineHeight: 1.4, letterSpacing: "0.12em", textTransform: "uppercase" }, nav: { family: "body", size: "0.75rem", weight: 600, lineHeight: 1, letterSpacing: "0.12em", textTransform: "uppercase" }, } as const satisfies Record; export type TypeToken = keyof typeof typeScale; /* ------------------------------------------------------------------ */ /* Space, layout, breakpoints */ /* ------------------------------------------------------------------ */ /** 8px rhythm. Values in px. */ export const space = { 1: 8, 2: 16, 3: 24, 4: 32, 6: 48, 8: 64, 9: 72, } as const; export const layout = { pageMaxWidth: 1500, pagePadding: "clamp(24px, 4vw, 64px)", sectionPadding: { desktop: 64, mobile: 48 }, headingGap: { desktop: 32, mobile: 24 }, caseStudyColumns: { desktop: 2, mobile: 1 }, portfolioColumns: { desktop: 3, tablet: 2, mobile: 2 }, portfolioGap: { row: 48, column: 32 }, /** Home header: max bio width and the portrait column beside it (desktop). */ header: { introMaxWidth: 820, gap: 48, portraitColumn: 300 }, /** Never run the portfolio wider than this. */ portfolioMaxColumns: 3, imageAspectRatio: "4 / 3", radius: { image: 2, icon: 6, swatch: 3, menuTab: 10 }, /** Minimum touch target on phones, in px. */ touchTarget: 48, } as const; /** Project pages (case studies, project stories, mini projects). */ export const projectPage = { /** Heading-left / body-right sections collapse to one column at this width. */ columnsCollapseAt: 900, /** The details column (Type, Role, Client…) stacks below this width. */ detailsStackAt: 620, /** Solid versions of the menu-band tint, used for the pinned header. */ headerBand: { dark: "#242629", light: "#ede2d9" }, /** The page's type goes in the details column as the first row, never above the title. */ typeLabel: "Type", } as const; /** Phone menu: a pull-down tray in the accent fill (≤ breakpoints.mobile). */ export const mobileMenu = { tab: { minWidth: 120, minHeight: 52, labelClosed: "Menu", labelOpen: "Close" }, rowHeight: 48, /** Text on the tray in both themes: 10.3:1 on teal, 6.1:1 on orange. */ text: "#141414", closesOn: ["link", "outside tap", "Escape"], duration: "320ms", } as const; /** The single link to the design-system reference. */ export const designSystemCallout = { icon: { size: 48, radius: 10, glyph: "palette", glyphSize: 26, hoverRotate: "15deg" }, title: "Check out the AI Design System behind this portfolio!", placement: { desktop: "below menu band", mobile: "above footer" }, } as const; /** Phone sections: collapsed behind their headings (≤ breakpoints.mobile). */ export const collapsibleSections = { startCollapsed: true, headingMinHeight: 48, /** Menu jumps stop below the pinned tab. */ scrollMarginTop: 64, } as const; /** Max-width breakpoints, in px. */ export const breakpoints = { tablet: 1050, mobile: 760, small: 480, } as const; export const media = { tablet: `(max-width: ${breakpoints.tablet}px)`, mobile: `(max-width: ${breakpoints.mobile}px)`, small: `(max-width: ${breakpoints.small}px)`, } as const; /* ------------------------------------------------------------------ */ /* Motion and the signature motif */ /* ------------------------------------------------------------------ */ export const motion = { easeOut: "cubic-bezier(0.2, 0.8, 0.2, 1)", fast: "180ms", base: "260ms", slow: "500ms", } as const; /** * The cut-paper offset, taken from the portrait: the cut-out sits up and * right of an accent silhouette. Images only, accent only, one per element. */ export const cutPaperOffset = { lift: { x: 4, y: -4 }, silhouette: { x: -8, y: 6 }, /** Ready-to-use values for the hover state. */ transform: "translate(4px, -4px)", boxShadow: (accent: string) => `-8px 6px 0 ${accent}`, transition: `transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 260ms cubic-bezier(0.2, 0.8, 0.2, 1)`, } as const; /* ------------------------------------------------------------------ */ /* Texture */ /* ------------------------------------------------------------------ */ /** * Two fixed, full-window layers between the background and the content, * both blended with soft-light. Every texture file is grayscale and * re-centered on 50% grey, so the background averages to its token. */ export const texture = { blendMode: "soft-light", crumple: { file: "assets/images/textures/texture-overlay-softlight.jpg", size: "cover", opacity: { light: 0.9, dark: 0.2 }, }, tooth: { file: "assets/images/textures/paper-tooth.png", size: "256px 256px", repeat: "repeat", opacity: { light: 0.35, dark: 0.3 }, }, } as const; /* ------------------------------------------------------------------ */ /* CSS custom properties */ /* ------------------------------------------------------------------ */ const kebab = (key: string) => key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); /** `{ "--background": "#2a2d30", ... }` for one theme. */ export function toCssVariables(theme: ThemeName): Record<`--${string}`, string> { const out: Record<`--${string}`, string> = {}; for (const [key, value] of Object.entries(themes[theme])) { out[`--${kebab(key)}`] = value; } out["--font-display"] = fontFamilies.display; out["--font-title"] = fontFamilies.title; out["--font-body"] = fontFamilies.body; return out; } const block = (selector: string, vars: Record) => `${selector} {\n${Object.entries(vars) .map(([k, v]) => ` ${k}: ${v};`) .join("\n")}\n}`; /** * A complete stylesheet: light theme by default, dark theme on * `html[data-theme="dark"]` (the attribute the site's theme toggle sets). */ export const cssVariablesStylesheet = [ block(":root", toCssVariables("light")), block('html[data-theme="dark"]', toCssVariables("dark")), ].join("\n\n"); export default { themes, fontFamilies, fontStylesheetUrl, typeScale, space, layout, breakpoints, media, projectPage, mobileMenu, collapsibleSections, designSystemCallout, motion, cutPaperOffset, texture, toCssVariables, cssVariablesStylesheet, };