;
@@ -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) => (
-