diff --git a/site/cssGen.mjs b/site/cssGen.mjs index 0aa2415c5d1..d0fdd4b22c6 100644 --- a/site/cssGen.mjs +++ b/site/cssGen.mjs @@ -1,13 +1,14 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { findAll, parse } from "css-tree"; +import { findAll, generate, parse } from "css-tree"; import glob from "fast-glob"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const themeFolder = "../packages/theme/css"; +const densities = ["high", "medium", "low", "touch", "mobile"]; function getTokensFromCssFile(cssFilePath) { const ast = parse(fs.readFileSync(cssFilePath, { encoding: "utf-8" })); @@ -22,6 +23,40 @@ function getTokensFromCssFile(cssFilePath) { }, {}); } +function getDensityTokensFromCssFile(cssFilePath) { + const ast = parse(fs.readFileSync(cssFilePath, { encoding: "utf-8" })); + + return findAll(ast, (node) => node.type === "Rule").reduce((acc, rule) => { + const selector = rule.prelude ? generate(rule.prelude) : ""; + const matchingDensities = densities.filter((density) => + selector.includes(`salt-density-${density}`), + ); + + if (matchingDensities.length === 0) { + return acc; + } + + const declarations = findAll( + rule, + (node) => + node.type === "Declaration" && node.property.startsWith("--salt"), + ); + + for (const declaration of declarations) { + const property = declaration.property; + const value = declaration.value.value.trim(); + + acc[property] ??= {}; + + for (const density of matchingDensities) { + acc[property][density] = value; + } + } + + return acc; + }, {}); +} + function writeObjectToFile(object, outputFile) { const jsonData = JSON.stringify(object, null, 2); const cssFolderPath = path.resolve( @@ -46,22 +81,35 @@ const characteristicFiles = glob.sync("*/characteristics/*.css", { }); const characteristicSets = {}; +const characteristicDensitySets = {}; for (const file of characteristicFiles) { const themeName = path.basename(path.dirname(path.dirname(file))); const tokens = getTokensFromCssFile(path.join(themeFolder, file)); + const densityTokens = getDensityTokensFromCssFile( + path.join(themeFolder, file), + ); characteristicSets[themeName] = { ...characteristicSets[themeName], ...tokens, }; + + characteristicDensitySets[themeName] = { + ...characteristicDensitySets[themeName], + ...densityTokens, + }; } for (const [themeName, tokens] of Object.entries(characteristicSets)) { writeObjectToFile(tokens, `cssCharacteristics-${themeName}.json`); } +for (const [themeName, tokens] of Object.entries(characteristicDensitySets)) { + writeObjectToFile(tokens, `cssCharacteristicsDensity-${themeName}.json`); +} + /* Generate CSS Foundations JSON */ const sharedFoundationFiles = glob.sync("foundations/*.css", { @@ -69,14 +117,23 @@ const sharedFoundationFiles = glob.sync("foundations/*.css", { }); let sharedFoundations = {}; +let sharedFoundationDensity = {}; for (const file of sharedFoundationFiles) { const tokens = getTokensFromCssFile(path.join(themeFolder, file)); + const densityTokens = getDensityTokensFromCssFile( + path.join(themeFolder, file), + ); sharedFoundations = { ...sharedFoundations, ...tokens, }; + + sharedFoundationDensity = { + ...sharedFoundationDensity, + ...densityTokens, + }; } const themeFoundationFiles = glob.sync("*/foundations/*.css", { @@ -84,16 +141,25 @@ const themeFoundationFiles = glob.sync("*/foundations/*.css", { }); const foundationSets = {}; +const foundationDensitySets = {}; for (const file of themeFoundationFiles) { const themeName = path.basename(path.dirname(path.dirname(file))); const tokens = getTokensFromCssFile(path.join(themeFolder, file)); + const densityTokens = getDensityTokensFromCssFile( + path.join(themeFolder, file), + ); foundationSets[themeName] = { ...foundationSets[themeName], ...tokens, }; + + foundationDensitySets[themeName] = { + ...foundationDensitySets[themeName], + ...densityTokens, + }; } for (const [themeName, tokens] of Object.entries(foundationSets)) { @@ -102,3 +168,10 @@ for (const [themeName, tokens] of Object.entries(foundationSets)) { `cssFoundations-${themeName}.json`, ); } + +for (const [themeName, tokens] of Object.entries(foundationDensitySets)) { + writeObjectToFile( + { ...sharedFoundationDensity, ...tokens }, + `cssFoundationsDensity-${themeName}.json`, + ); +} diff --git a/site/docs/themes/design-tokens/brand.mdx b/site/docs/themes/design-tokens/brand.mdx deleted file mode 100644 index 81ea2a9f9e0..00000000000 --- a/site/docs/themes/design-tokens/brand.mdx +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: JPM brand characteristic tokens -layout: DetailTechnical -sidebar: - label: JPM brand tokens - priority: 2 ---- - -## Accent - -Accent highlights key elements by applying a theme's dominant color, helping important components—like active tabs or headings—stand out and draw attention. - - - -## Actionable - -Actionable defines the colors and styles for components that enable onward actions, such as triggering events or processes, making interactive elements clearly identifiable and intuitive for users. - - - -## Category - -Category offers a set of distinct colors for grouping and differentiating content, with meaning assigned locally. - - - -## Container - -Container sets the background and structure of interface elements, using variants and optional borders to group content and create visual hierarchy. - - - -## Content - -Content defines the standard foreground colors for text and icons, supporting clarity, consistency, and accessibility. - - - -## Editable - -Editable defines the styles for input components that allow users to enter or modify data, such as text fields and input controls. - - - -## Focused - -Focused defines the styles for components that can receive focus via mouse or keyboard interaction. - - - -## Navigable - -Navigable defines the styles for components that enable users to move between sections, pages, or data sets, such as tabs and navigation menus. - - - -## Overlayable - -Overlayable provides translucent styles for shadows, scrims, and neutral overlays, allowing highlights without obscuring underlying colors or states. - - - -## Selectable - -Selectable defines the styles for components that allow users to make selections, such as radio buttons, checkboxes, switches, and selectable list items. - - - -## Sentiment - -Sentiment offers visual cues that evoke feelings such as positivity, negativity, caution, or neutrality, helping to reinforce meaning and highlight important elements. - - - -## Separable - -Separable defines visual dividers that organize and structure layouts, with primary, secondary, and tertiary variants providing different levels of emphasis to support clear hierarchy and readability. - - - -## Status - -Status provides visual cues that communicate the condition of a system or process, using values like error, info, warning, and success to highlight issues and indicate when action may be needed. - - - -## Target - -Target defines the styles for areas where draggable items, such as documents for attaching or uploading, can be dropped. - - - -## Text - -Text defines all typographic styles and variants, such as font weight, and is used alongside other characteristics to style all textual content. All text examples are shown using the default fontWeight token. - - diff --git a/site/docs/themes/design-tokens/legacy.mdx b/site/docs/themes/design-tokens/legacy.mdx deleted file mode 100644 index 781dc790403..00000000000 --- a/site/docs/themes/design-tokens/legacy.mdx +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Legacy (UITK) characteristics tokens -layout: DetailTechnical -sidebar: - label: Legacy (UITK) tokens - priority: 1 ---- - -## Accent - -Accent highlights key elements by applying a theme's dominant color, helping important components—like active tabs or headings—stand out and draw attention. - - - -## Actionable - -Actionable defines the colors and styles for components that enable onward actions, such as triggering events or processes, making interactive elements clearly identifiable and intuitive for users. - - - -## Category - -Category offers a set of distinct colors for grouping and differentiating content, with meaning assigned locally. - - - -## Container - -Container sets the background and structure of interface elements, using variants and optional borders to group content and create visual hierarchy. - - - -## Content - -Content defines the standard foreground colors for text and icons, supporting clarity, consistency, and accessibility. - - - -## Editable - -Editable defines the styles for input components that allow users to enter or modify data, such as text fields and input controls. - - - -## Focused - -Focused defines the styles for components that can receive focus via mouse or keyboard interaction. - - - -## Navigable - -Navigable defines the styles for components that enable users to move between sections, pages, or data sets, such as tabs and navigation menus. - - - -## Overlayable - -Overlayable provides translucent styles for shadows, scrims, and neutral overlays, allowing highlights without obscuring underlying colors or states. - - - -## Selectable - -Selectable defines the styles for components that allow users to make selections, such as radio buttons, checkboxes, switches, and selectable list items. - - - -## Sentiment - -Sentiment offers visual cues that evoke feelings such as positivity, negativity, caution, or neutrality, helping to reinforce meaning and highlight important elements. - - - -## Separable - -Separable defines visual dividers that organize and structure layouts, with primary, secondary, and tertiary variants providing different levels of emphasis to support clear hierarchy and readability. - - - -## Status - -Status provides visual cues that communicate the condition of a system or process, using values like error, info, warning, and success to highlight issues and indicate when action may be needed. - - - -## Target - -Target defines the styles for areas where draggable items, such as documents for attaching or uploading, can be dropped. - - - -## Text - -Text defines all typographic styles and variants, such as font weight, and is used alongside other characteristics to style all textual content. All text examples are shown using the default fontWeight token. - - diff --git a/site/docs/themes/design-tokens/tokens.mdx b/site/docs/themes/design-tokens/tokens.mdx new file mode 100644 index 00000000000..650a8ca1914 --- /dev/null +++ b/site/docs/themes/design-tokens/tokens.mdx @@ -0,0 +1,10 @@ +--- +title: Token index +description: "The token index provides a complete listing of all Salt's design tokens, along with their corresponding values. This resource makes it easy to review and reference tokens across different modes, themes and densities, helping designers and developers maintain consistency and makin informed choices when building with Salt. Figma variables for each design token can be found in the Salt DS Themes Library." +layout: DetailTechnical +sidebar: + priority: 1 +--- + + + diff --git a/site/src/components/callout/Callout.tsx b/site/src/components/callout/Callout.tsx index 1d2882662b9..4f783d7d1a1 100644 --- a/site/src/components/callout/Callout.tsx +++ b/site/src/components/callout/Callout.tsx @@ -1,8 +1,8 @@ import { Banner, BannerContent, H4, Text } from "@salt-ds/core"; -import type { ReactNode } from "react"; +import type { ComponentPropsWithoutRef, ReactNode } from "react"; import styles from "./Callout.module.css"; -type CalloutProps = { +type CalloutProps = ComponentPropsWithoutRef & { title: string; children: ReactNode; }; diff --git a/site/src/components/css-display/AllTokens.module.css b/site/src/components/css-display/AllTokens.module.css new file mode 100644 index 00000000000..e1e0b6d15e5 --- /dev/null +++ b/site/src/components/css-display/AllTokens.module.css @@ -0,0 +1,91 @@ +.container { + +} + +.filterInput { + max-width: 24rem; +} + +.tableWrap { + max-width: 100%; + overflow-x: auto; +} + +.tokenCell { + min-width: 24rem; +} + +.codeWrapper { + min-height: var(--salt-size-base); + display: inline-flex; + align-items: center; +} + +.tokenRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--salt-spacing-100); +} + +.swatch { + width: var(--salt-size-base); + height: var(--salt-size-base); + border-radius: var(--salt-palette-corner-weaker); + border-width: var(--salt-size-fixed-100); + border-style: var(--salt-borderStyle-solid); + border-color: var(--salt-container-primary-borderColor); + background: var(--salt-container-primary-background); + overflow: hidden; +} + +.swatchInner { + width: 100%; + height: 100%; +} + +.swatchColor { + background-color: var(--preview-background-color); + background-image: var(--preview-background); +} + +.swatchShadow, +.swatchBorderStyle, +.swatchBorderWidth, +.swatchOutline { + background: var(--salt-container-primary-background); +} + +.swatchShadow { + box-shadow: var(--preview-box-shadow); +} + +.swatchBorderStyle { + border-style: var(--preview-border-style); +} + +.swatchBorderWidth { + border-width: var(--preview-border-width); +} + +.swatchOutline { + outline: var(--preview-outline); +} + +.checkerboard { + background-image: linear-gradient(45deg, #e7e7e7 25%, transparent 25%, transparent 75%, #e7e7e7 75%), linear-gradient(45deg, #e7e7e7 25%, #fff 25%, #fff 75%, #e7e7e7 75%); + background-size: 8px 8px; + background-position: + -4px 4px, + 16px 16px; +} + +.valueCode { + display: inline-block; + max-width: 18rem; + overflow-wrap: anywhere; +} + +.loading { + margin: var(--salt-spacing-300) auto; +} diff --git a/site/src/components/css-display/AllTokens.tsx b/site/src/components/css-display/AllTokens.tsx new file mode 100644 index 00000000000..9df2efaf592 --- /dev/null +++ b/site/src/components/css-display/AllTokens.tsx @@ -0,0 +1,374 @@ +import { + Button, + capitalize, + Dropdown, + FlexLayout, + FormField, + FormFieldLabel, + H2, + H3, + Input, + Option, + Spinner, + StackLayout, + Table, + TBody, + TD, + Text, + TH, + THead, + ToggleButton, + ToggleButtonGroup, + TR, +} from "@salt-ds/core"; +import { CloseIcon, SearchIcon } from "@salt-ds/icons"; +import { useEffect, useState } from "react"; +import { Callout } from "../callout"; +import { CopyToClipboard } from "../copy-to-clipboard"; +import styles from "./AllTokens.module.css"; +import descriptions from "./descriptions"; +import { TokenPreview } from "./TokenPreview"; +import { + filterFoundationTokens, + groupTokens, + type TokenGroups, +} from "./tokenData"; + +type CssVariableData = Record; +type Density = "high" | "medium" | "low" | "touch" | "mobile"; +type DensityOverrides = Partial< + Record>> +>; +type ThemeType = "next" | "legacy"; +type Mode = "light" | "dark"; +type TokenTier = "characteristic" | "foundation"; +type TokenTableData = { + characteristics: TokenGroups | null; + foundations: TokenGroups | null; + characteristicDensity: DensityOverrides | null; + foundationDensity: DensityOverrides | null; +}; + +function getSectionHeadingId(tier: TokenTier) { + return `${tier}-tokens`; +} + +function getGroupHeadingId(tier: TokenTier, group: string) { + return `${tier}-${group}`.replace(/[^a-z0-9-]/gi, "-").toLowerCase(); +} +type ThemeTokenTables = Record; + +const themes = [ + { + displayName: "JPM Brand", + value: "next", + }, + { + displayName: "Legacy", + value: "legacy", + }, +]; + +export function AllTokens() { + const [theme, setTheme] = useState("next"); + const [density, setDensity] = useState("medium"); + const [mode, setMode] = useState("light"); + const [filterText, setFilterText] = useState(""); + const [themeTables, setThemeTables] = useState(null); + + useEffect(() => { + let active = true; + + const load = async () => { + const [ + nextFoundations, + legacyFoundations, + nextCharacteristics, + legacyCharacteristics, + nextFoundationDensity, + legacyFoundationDensity, + nextCharacteristicDensity, + legacyCharacteristicDensity, + ] = await Promise.all([ + import("./cssFoundations-next.json"), + import("./cssFoundations-legacy.json"), + import("./cssCharacteristics-next.json"), + import("./cssCharacteristics-legacy.json"), + import("./cssFoundationsDensity-next.json"), + import("./cssFoundationsDensity-legacy.json"), + import("./cssCharacteristicsDensity-next.json"), + import("./cssCharacteristicsDensity-legacy.json"), + ]); + + if (!active) { + return; + } + + setThemeTables({ + next: { + characteristics: groupTokens( + nextCharacteristics.default as CssVariableData, + ), + foundations: groupTokens( + filterFoundationTokens(nextFoundations.default as CssVariableData), + ), + characteristicDensity: + nextCharacteristicDensity.default as DensityOverrides, + foundationDensity: nextFoundationDensity.default as DensityOverrides, + }, + legacy: { + characteristics: groupTokens( + legacyCharacteristics.default as CssVariableData, + ), + foundations: groupTokens( + filterFoundationTokens( + legacyFoundations.default as CssVariableData, + ), + ), + characteristicDensity: + legacyCharacteristicDensity.default as DensityOverrides, + foundationDensity: + legacyFoundationDensity.default as DensityOverrides, + }, + }); + }; + + load(); + + return () => { + active = false; + }; + }, []); + + const tables = themeTables?.[theme] ?? { + characteristics: null, + foundations: null, + characteristicDensity: null, + foundationDensity: null, + }; + + const hasCharacteristicResults = + tables.characteristics !== null && + Object.keys(filterTokenGroups(tables.characteristics, filterText)).length > + 0; + const hasFoundationResults = + tables.foundations !== null && + Object.keys(filterTokenGroups(tables.foundations, filterText)).length > 0; + const showEmptyState = + filterText.trim() !== "" && + tables.characteristics !== null && + tables.foundations !== null && + !hasCharacteristicResults && + !hasFoundationResults; + + return ( + + + + Theme + { + setTheme(value[0] as ThemeType); + }} + valueToString={(value) => + themes.find((t) => t.value === value)?.displayName || value + } + > + {themes.map(({ value }) => ( + + + + Density + { + setDensity(value[0] as Density); + }} + valueToString={(value) => capitalize(value)} + > + + + setMode(event.currentTarget.value as Mode)} + > + Light + Dark + + + + Search tokens + } + endAdornment={ + filterText ? ( + + ) : null + } + className={styles.filterInput} + value={filterText} + inputProps={{ + onChange: (event) => setFilterText(event.target.value), + }} + /> + + {showEmptyState ? ( + + No tokens match "{filterText.trim()}". Try a different token + name or prefix. + + ) : null} + + + + + ); +} + +function TokenTable({ + title, + tier, + groupedRows, + densityOverrides, + filterText, + loadingLabel, + density, + mode, + theme, +}: { + title: string; + tier: TokenTier; + groupedRows: TokenGroups | null; + densityOverrides: DensityOverrides | null; + filterText: string; + loadingLabel: string; + density: Density; + mode: Mode; + theme: ThemeType; +}) { + if (groupedRows === null) { + return ( + + ); + } + + const filteredRows = filterTokenGroups(groupedRows, filterText); + const themeKey = `${tier}-${theme}-${mode}`; + const visibleGroups = Object.entries(filteredRows); + + if (visibleGroups.length === 0) { + return null; + } + + return ( + +

+ {capitalize(title)} +

+ {visibleGroups.map(([group, rows]) => ( + +

+ {capitalize(group)} +

+ {descriptions[group.toLowerCase()]} +
+ + + + + + + + + {rows.map(([name, value]) => { + const resolvedValue = + densityOverrides?.[name]?.[density] ?? value; + + return ( + + + + + ); + })} + +
ValueToken
+ + +
+ +
+
+
+
+ ))} +
+ ); +} + +function filterTokenGroups(groupedRows: TokenGroups, filterText: string) { + const normalizedFilter = filterText.trim().toLowerCase(); + + if (normalizedFilter === "") { + return groupedRows; + } + + return Object.fromEntries( + Object.entries(groupedRows) + .map(([group, rows]) => [ + group, + rows.filter(([name]) => name.toLowerCase().includes(normalizedFilter)), + ]) + .filter(([, rows]) => rows.length > 0), + ) as TokenGroups; +} diff --git a/site/src/components/css-display/CharacteristicsTokenTable.tsx b/site/src/components/css-display/CharacteristicsTokenTable.tsx index 4b79f774ef5..346503259d9 100644 --- a/site/src/components/css-display/CharacteristicsTokenTable.tsx +++ b/site/src/components/css-display/CharacteristicsTokenTable.tsx @@ -16,6 +16,7 @@ import { CopyToClipboard } from "../copy-to-clipboard"; import { Code } from "../mdx/code"; import { BlockView } from "./BlockView"; import styles from "./CharacteristicsTokenTable.module.css"; +import { formatTokenValue } from "./formatTokenValue"; const groupByType = (data: CssVariableData) => { const groupedData: { [key: string]: CssVariableData } = {}; @@ -100,7 +101,7 @@ export const CharacteristicsTokenTable = ({ - {value} + {formatTokenValue(value)} diff --git a/site/src/components/css-display/FoundationColorView.tsx b/site/src/components/css-display/FoundationColorView.tsx index 4de25ef76b3..75ccc668eb3 100644 --- a/site/src/components/css-display/FoundationColorView.tsx +++ b/site/src/components/css-display/FoundationColorView.tsx @@ -13,6 +13,7 @@ import { } from "@salt-ds/core"; import { useEffect, useState } from "react"; import { CopyToClipboard } from "../copy-to-clipboard"; +import { formatTokenValue } from "./formatTokenValue"; import { ColorBlock } from "./style-blocks/ColorBlock"; type CssVariableData = Record; @@ -86,7 +87,7 @@ const ColorTable = ({ - {value} + {formatTokenValue(value)} ))} diff --git a/site/src/components/css-display/TokenPreview.tsx b/site/src/components/css-display/TokenPreview.tsx new file mode 100644 index 00000000000..9db0ea68f15 --- /dev/null +++ b/site/src/components/css-display/TokenPreview.tsx @@ -0,0 +1,90 @@ +import { SaltProvider, SaltProviderNext } from "@salt-ds/core"; +import { clsx } from "clsx"; +import { useEffect, useRef, useState } from "react"; +import { Code } from "../mdx/code"; +import styles from "./AllTokens.module.css"; +import { formatTokenValue } from "./formatTokenValue"; +import { getPreviewType, getSwatchStyle } from "./tokenPreviewUtils"; + +export function TokenPreview({ + name, + value, + mode, + themeKey, + theme, +}: { + name: string; + value: string; + mode: "light" | "dark"; + themeKey: string; + theme: "next" | "legacy"; +}) { + const type = getPreviewType(name, value); + const [isTransparent, setIsTransparent] = useState(false); + const swatchRef = useRef(null); + const ThemeProvider = theme === "next" ? SaltProviderNext : SaltProvider; + const swatchVersion = `${themeKey}:${value}`; + + useEffect(() => { + if (type !== "color") { + setIsTransparent(false); + return; + } + + const node = swatchRef.current; + if (!node) { + return; + } + + if (node.dataset.swatchVersion !== swatchVersion) { + return; + } + + const normalized = window + .getComputedStyle(node) + .backgroundColor.replaceAll(" ", "") + .toLowerCase(); + + setIsTransparent( + normalized === "transparent" || + normalized.includes("rgba(0,0,0,0)") || + normalized.endsWith(",0)"), + ); + }, [swatchVersion, type]); + + if (type === "raw") { + return
{formatTokenValue(value)}
; + } + + return ( +
+ +
+ +
+ ); +} + +function getSwatchClassName( + type: Exclude, "raw">, +) { + switch (type) { + case "color": + return styles.swatchColor; + case "shadow": + return styles.swatchShadow; + case "borderStyle": + return styles.swatchBorderStyle; + case "borderWidth": + return styles.swatchBorderWidth; + case "outline": + return styles.swatchOutline; + } +} diff --git a/site/src/components/css-display/descriptions.ts b/site/src/components/css-display/descriptions.ts index a8a8ac6758e..ecbdccdbf2c 100644 --- a/site/src/components/css-display/descriptions.ts +++ b/site/src/components/css-display/descriptions.ts @@ -28,4 +28,4 @@ export default { target: "Target defines the styles for areas where draggable items, such as documents for attaching or uploading, can be dropped.", text: "Text defines all typographic styles and variants, such as font weight, and is used alongside other characteristics to style all textual content.", -}; +} as Record; diff --git a/site/src/components/css-display/formatTokenValue.ts b/site/src/components/css-display/formatTokenValue.ts new file mode 100644 index 00000000000..d412e2a92a5 --- /dev/null +++ b/site/src/components/css-display/formatTokenValue.ts @@ -0,0 +1,29 @@ +export function formatTokenValue(value: string) { + let formattedValue = value.trim(); + let hasChanged = false; + + while (true) { + if (formattedValue.startsWith("var(") && formattedValue.endsWith(")")) { + formattedValue = formattedValue.slice(4, -1).trim(); + hasChanged = true; + continue; + } + + if (hasMatchingWrappingQuotes(formattedValue)) { + formattedValue = formattedValue.slice(1, -1).trim(); + hasChanged = true; + continue; + } + + break; + } + + return hasChanged ? formattedValue : value; +} + +function hasMatchingWrappingQuotes(value: string) { + return ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ); +} diff --git a/site/src/components/css-display/index.ts b/site/src/components/css-display/index.ts index 4a4859e88da..fd9e8a910a6 100644 --- a/site/src/components/css-display/index.ts +++ b/site/src/components/css-display/index.ts @@ -1,3 +1,4 @@ +export * from "./AllTokens"; export * from "./BlockView"; export * from "./CharacteristicsTokenTable"; export * from "./FoundationColorView"; diff --git a/site/src/components/css-display/tokenData.ts b/site/src/components/css-display/tokenData.ts new file mode 100644 index 00000000000..b841b1d17cc --- /dev/null +++ b/site/src/components/css-display/tokenData.ts @@ -0,0 +1,30 @@ +type CssVariableData = Record; +export type TokenGroups = Record>; + +export function groupTokens(data: CssVariableData): TokenGroups { + return Object.entries(data) + .sort(([a], [b]) => a.localeCompare(b)) + .reduce((acc, [name, value]) => { + const group = getTokenGroup(name); + if (!acc[group]) { + acc[group] = []; + } + acc[group].push([name, value]); + return acc; + }, {}); +} + +export function filterFoundationTokens(data: CssVariableData): CssVariableData { + return Object.fromEntries( + Object.entries(data).filter(([name]) => !isFoundationColorToken(name)), + ); +} + +function isFoundationColorToken(name: string) { + return name.startsWith("--salt-color-"); +} + +function getTokenGroup(name: string): string { + const match = /^--salt-([a-zA-Z0-9]+)-/.exec(name); + return match?.[1] ?? "other"; +} diff --git a/site/src/components/css-display/tokenPreviewUtils.ts b/site/src/components/css-display/tokenPreviewUtils.ts new file mode 100644 index 00000000000..ba1405a545c --- /dev/null +++ b/site/src/components/css-display/tokenPreviewUtils.ts @@ -0,0 +1,198 @@ +import type { CSSProperties } from "react"; + +export type PreviewType = + | "color" + | "shadow" + | "borderStyle" + | "borderWidth" + | "outline" + | "raw"; + +type TokenProperty = + | "background" + | "borderColor" + | "color" + | "outline" + | "outlineColor" + | "shadow" + | "borderStyle" + | "outlineStyle" + | "borderWidth" + | "outlineWidth" + | "fontFamily" + | "fontWeight" + | "other"; + +export function getPreviewType(name: string, value: string): PreviewType { + const property = inferTokenProperty(name); + + switch (property) { + case "background": + case "borderColor": + case "color": + case "outlineColor": + return isColorLikeValue(value) ? "color" : "raw"; + case "shadow": + return isShadowValue(value) ? "shadow" : "raw"; + case "outline": + return isOutlineValue(value) ? "outline" : "raw"; + case "borderStyle": + case "outlineStyle": + return isBorderStyleValue(value) ? "borderStyle" : "raw"; + case "borderWidth": + case "outlineWidth": + return isLengthValue(value) ? "borderWidth" : "raw"; + case "fontFamily": + case "fontWeight": + return "raw"; + default: + if (isShadowValue(value)) { + return "shadow"; + } + + if (isOutlineValue(value)) { + return "outline"; + } + + if (isExplicitBorderStyleReference(value)) { + return "borderStyle"; + } + + if (isColorLikeValue(value)) { + return "color"; + } + + return "raw"; + } +} + +export function getSwatchStyle( + type: PreviewType, + value: string, +): CSSProperties | undefined { + switch (type) { + case "color": + return isGradientValue(value) + ? ({ "--preview-background": value } as CSSProperties) + : ({ "--preview-background-color": value } as CSSProperties); + case "shadow": + return { "--preview-box-shadow": value } as CSSProperties; + case "borderStyle": + return { "--preview-border-style": value } as CSSProperties; + case "borderWidth": + return { "--preview-border-width": value } as CSSProperties; + case "outline": + return { "--preview-outline": value } as CSSProperties; + default: + return undefined; + } +} + +export function isGradientValue(value: string) { + const normalized = value.trim().toLowerCase(); + return ( + normalized.startsWith("linear-gradient(") || + normalized.startsWith("radial-gradient(") || + normalized.startsWith("conic-gradient(") || + normalized.startsWith("repeating-linear-gradient(") || + normalized.startsWith("repeating-radial-gradient(") || + normalized.startsWith("repeating-conic-gradient(") + ); +} + +function isBorderStyleValue(value: string) { + const normalized = value.trim().toLowerCase(); + return ( + /^(solid|dashed|dotted|double|none)$/i.test(normalized) || + normalized.startsWith("var(--salt-borderstyle-") + ); +} + +function isExplicitBorderStyleReference(value: string) { + return value.trim().toLowerCase().startsWith("var(--salt-borderstyle-"); +} + +function isShadowValue(value: string) { + const normalized = value.trim().toLowerCase(); + return ( + normalized.startsWith("var(--salt-shadow-") || + normalized.includes("inset") || + /(-?\d+(\.\d+)?px\s+){2,}/.test(normalized) + ); +} + +function isOutlineValue(value: string) { + const normalized = value.trim().toLowerCase(); + return ( + normalized.startsWith("var(--salt-focused-outline") || + normalized.startsWith("var(--salt-outline") || + normalized.includes("outline") + ); +} + +function isColorLikeValue(value: string) { + const normalized = value.trim().toLowerCase(); + return ( + normalized === "transparent" || + isGradientValue(normalized) || + normalized.startsWith("#") || + normalized.startsWith("rgb(") || + normalized.startsWith("rgba(") || + normalized.startsWith("hsl(") || + normalized.startsWith("hsla(") || + normalized.startsWith("oklch(") || + normalized.startsWith("var(--salt-color-") || + normalized.startsWith("var(--salt-palette-") + ); +} + +function isLengthValue(value: string) { + const normalized = value.trim().toLowerCase(); + return ( + /^-?\d+(\.\d+)?px$/.test(normalized) || + /^var\(--salt-[a-z0-9-]+\)$/.test(normalized) + ); +} + +function inferTokenProperty(name: string): TokenProperty { + const normalized = name.toLowerCase(); + + if (normalized.endsWith("-background")) { + return "background"; + } + if (normalized.endsWith("-bordercolor")) { + return "borderColor"; + } + if (normalized.endsWith("-color")) { + return "color"; + } + if (normalized.endsWith("-fontfamily")) { + return "fontFamily"; + } + if (normalized.endsWith("-fontweight")) { + return "fontWeight"; + } + if (normalized.endsWith("-outline")) { + return "outline"; + } + if (normalized.endsWith("-outlinecolor")) { + return "outlineColor"; + } + if (normalized.endsWith("-shadow")) { + return "shadow"; + } + if (normalized.endsWith("-borderstyle")) { + return "borderStyle"; + } + if (normalized.endsWith("-outlinestyle")) { + return "outlineStyle"; + } + if (normalized.endsWith("-borderwidth")) { + return "borderWidth"; + } + if (normalized.endsWith("-outlinewidth")) { + return "outlineWidth"; + } + + return "other"; +} diff --git a/site/src/components/toc/TableOfContents.tsx b/site/src/components/toc/TableOfContents.tsx index f78c111d2dc..f5b2d0464c6 100644 --- a/site/src/components/toc/TableOfContents.tsx +++ b/site/src/components/toc/TableOfContents.tsx @@ -12,19 +12,54 @@ import styles from "./TableOfContents.module.css"; const stripMarkdownLinks = (text: string) => text.replace(/\[([^[\]]*)\]\((.*?)\)/gm, "$1"); -function getHeaderAnchors(): HTMLAnchorElement[] { +type TocItem = { + id: string; + level: number; + text: string; +}; + +function getTocRoot(): ParentNode { // Some layout (e.g. components) has multiple tabs with hidden headings const visibleTabPanel = document.querySelector( '[role="tabpanel"]:not([hidden])', ); + return ( + visibleTabPanel ?? document.querySelector(".contentWrapper") ?? document + ); +} + +function getHeaderElements(): HTMLElement[] { return Array.from( - (visibleTabPanel ?? document).querySelectorAll('[data-mdx="heading2"]'), - ).map((testElement) => { - return testElement.parentNode as HTMLAnchorElement; + getTocRoot().querySelectorAll( + '[data-mdx="heading2"], [data-mdx="heading3"]', + ), + ) as HTMLElement[]; +} + +function getHeaderAnchors(): HTMLElement[] { + return getHeaderElements().map((headerElement) => { + const anchorParent = headerElement.closest("a"); + return (anchorParent ?? headerElement) as HTMLElement; }); } +function getDomTableOfContents(): TocItem[] { + return getHeaderElements() + .map((headerElement) => { + if (!headerElement.id) { + return null; + } + + return { + id: headerElement.id, + level: headerElement.dataset.mdx === "heading3" ? 3 : 2, + text: headerElement.textContent?.trim() ?? "", + } satisfies TocItem; + }) + .filter((item): item is TocItem => item !== null && item.text.length > 0); +} + const TOP_OFFSET = 80; /** Header height */ function useTocHighlight(topOffset = TOP_OFFSET) { @@ -90,19 +125,64 @@ function useTocHighlight(topOffset = TOP_OFFSET) { export function TableOfContents(props: ComponentPropsWithoutRef<"aside">) { const { className, ...rest } = props; const { tableOfContents } = useTableOfContents(); + const [fallbackTableOfContents, setFallbackTableOfContents] = useState< + TocItem[] + >([]); const [showTOC, setShowTOC] = useState(false); const headingId = useId(); + const observerRef = useRef(null); + const observedRootRef = useRef(null); const { currentIndex } = useTocHighlight(); useEffect(() => { + const updateFallbackToc = () => { + if (tableOfContents.length === 0) { + setFallbackTableOfContents(getDomTableOfContents()); + } + }; + const handle = window.requestIdleCallback(() => { + updateFallbackToc(); setShowTOC(true); }); - return () => window.cancelIdleCallback(handle); - }, []); + const ensureObservedRoot = () => { + const nextRoot = getTocRoot(); + + if (observedRootRef.current === nextRoot) { + return; + } + + observerRef.current?.disconnect(); + + const observer = new MutationObserver(() => { + updateFallbackToc(); + ensureObservedRoot(); + }); + + observer.observe(nextRoot, { + childList: true, + subtree: true, + }); + + observerRef.current = observer; + observedRootRef.current = nextRoot; + }; + + ensureObservedRoot(); + + return () => { + window.cancelIdleCallback(handle); + observerRef.current?.disconnect(); + observerRef.current = null; + observedRootRef.current = null; + }; + }, [tableOfContents]); + + const items = + tableOfContents.length > 0 ? tableOfContents : fallbackTableOfContents; - if (!showTOC || tableOfContents.length === 0) { + if (!showTOC || items.length === 0) { return null; } @@ -116,7 +196,7 @@ export function TableOfContents(props: ComponentPropsWithoutRef<"aside">) { On this page
    - {tableOfContents.map((item, index) => ( + {items.map((item, index) => (