diff --git a/src/prototypes/example-codex-kitchen-sink/index.vue b/src/prototypes/example-codex-kitchen-sink/index.vue new file mode 100644 index 00000000..0cd97a9b --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/index.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/lib/color-contrast.ts b/src/prototypes/example-codex-kitchen-sink/lib/color-contrast.ts new file mode 100644 index 00000000..46127f37 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/lib/color-contrast.ts @@ -0,0 +1,99 @@ +interface Rgba { + r: number + g: number + b: number + a: number +} + +function parseColor(color: string): Rgba | null { + if (!color || color === 'transparent') { + return { r: 0, g: 0, b: 0, a: 0 } + } + + const rgbaMatch = color.match( + /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?\s*\)/, + ) + if (rgbaMatch) { + return { + r: Number(rgbaMatch[1]), + g: Number(rgbaMatch[2]), + b: Number(rgbaMatch[3]), + a: rgbaMatch[4] !== undefined ? Number(rgbaMatch[4]) : 1, + } + } + + const hexMatch = color.match(/^#([0-9a-f]{3,8})$/i) + if (!hexMatch) return null + + let hex = hexMatch[1] + if (hex.length === 3) { + hex = hex + .split('') + .map((char) => char + char) + .join('') + } + + if (hex.length < 6) return null + + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + a: 1, + } +} + +function relativeLuminance(r: number, g: number, b: number): number { + const channel = (value: number) => { + const normalized = value / 255 + return normalized <= 0.03928 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4 + } + + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) +} + +function compositeOver(fg: Rgba, bg: Rgba): [number, number, number] { + if (fg.a <= 0) return [bg.r, bg.g, bg.b] + if (fg.a >= 1) return [fg.r, fg.g, fg.b] + + const backdropAlpha = bg.a * (1 - fg.a) + const outAlpha = fg.a + backdropAlpha + if (outAlpha <= 0) return [bg.r, bg.g, bg.b] + + return [ + Math.round((fg.r * fg.a + bg.r * backdropAlpha) / outAlpha), + Math.round((fg.g * fg.a + bg.g * backdropAlpha) / outAlpha), + Math.round((fg.b * fg.a + bg.b * backdropAlpha) / outAlpha), + ] +} + +function getCanvasColor(): Rgba | null { + return parseColor(getComputedStyle(document.documentElement).backgroundColor) +} + +function resolvedBackgroundLuminance(element: HTMLElement): number | null { + const canvasColor = getCanvasColor() + const backgroundColor = parseColor(getComputedStyle(element).backgroundColor) + if (!backgroundColor || !canvasColor) return null + + const [r, g, b] = compositeOver(backgroundColor, canvasColor) + return relativeLuminance(r, g, b) +} + +export type SwatchTextTone = 'light' | 'dark' + +export function getSwatchTextToneForBackground(element: HTMLElement): SwatchTextTone { + const luminance = resolvedBackgroundLuminance(element) + if (luminance === null) return 'dark' + + return luminance > 0.5 ? 'dark' : 'light' +} + +export function needsInvertedTextBackground(element: HTMLElement): boolean { + const color = parseColor(getComputedStyle(element).color) + if (!color) return false + + return relativeLuminance(color.r, color.g, color.b) > 0.65 +} diff --git a/src/prototypes/example-codex-kitchen-sink/lib/component-tabs.ts b/src/prototypes/example-codex-kitchen-sink/lib/component-tabs.ts new file mode 100644 index 00000000..62a58975 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/lib/component-tabs.ts @@ -0,0 +1,70 @@ +export const buttonsSubTabs = [ + { id: 'button', label: 'Button' }, + { id: 'button-group', label: 'ButtonGroup' }, + { id: 'menu-button', label: 'MenuButton' }, + { id: 'toggle-button', label: 'ToggleButton' }, + { id: 'toggle-button-group', label: 'ToggleButtonGroup' }, +] as const + +export const formElementsSubTabs = [ + { id: 'checkbox', label: 'Checkbox' }, + { id: 'chip-input', label: 'ChipInput' }, + { id: 'combobox', label: 'Combobox' }, + { id: 'field', label: 'Field' }, + { id: 'label', label: 'Label' }, + { id: 'lookup', label: 'Lookup' }, + { id: 'multiselect-lookup', label: 'MultiselectLookup' }, + { id: 'radio', label: 'Radio' }, + { id: 'select', label: 'Select' }, + { id: 'text-area', label: 'TextArea' }, + { id: 'text-input', label: 'TextInput' }, + { id: 'toggle-switch', label: 'ToggleSwitch' }, +] as const + +export const contentDataSubTabs = [ + { id: 'accordion', label: 'Accordion' }, + { id: 'card', label: 'Card' }, + { id: 'dialog', label: 'Dialog' }, + { id: 'menu', label: 'Menu' }, + { id: 'menu-item', label: 'MenuItem' }, + { id: 'popover', label: 'Popover' }, + { id: 'table', label: 'Table' }, + { id: 'tooltip', label: 'Tooltip' }, +] as const + +export const feedbackSubTabs = [ + { id: 'info-chip', label: 'InfoChip' }, + { id: 'message', label: 'Message' }, + { id: 'progress-bar', label: 'ProgressBar' }, + { id: 'progress-indicator', label: 'ProgressIndicator' }, + { id: 'toast', label: 'Toast' }, +] as const + +export const mediaSubTabs = [ + { id: 'image', label: 'Image' }, + { id: 'thumbnail', label: 'Thumbnail' }, +] as const + +export const navigationSubTabs = [ + { id: 'link', label: 'Link' }, + { id: 'tabs', label: 'Tabs' }, +] as const + +export const searchSubTabs = [ + { id: 'search-input', label: 'SearchInput' }, + { id: 'typeahead-search', label: 'TypeaheadSearch' }, + { id: 'search-result-title', label: 'SearchResultTitle' }, +] as const + +export const iconsSubTabs = [ + { id: 'size', label: 'Size' }, + { id: 'type', label: 'Type' }, +] as const + +/** Codex 2.x IconSizes — xx-small was removed and no longer applies sizing. */ +export const iconSizeEntries = [ + { id: 'x-small', deprecated: false }, + { id: 'small', deprecated: false }, + { id: 'medium', deprecated: false }, + { id: 'xx-small', deprecated: true }, +] as const diff --git a/src/prototypes/example-codex-kitchen-sink/lib/fixtures.ts b/src/prototypes/example-codex-kitchen-sink/lib/fixtures.ts new file mode 100644 index 00000000..debc51ea --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/lib/fixtures.ts @@ -0,0 +1,113 @@ +import { cdxIconAdd, cdxIconEdit, cdxIconTrash } from '@wikimedia/codex-icons' +import type { ChipInputItem, MenuItemData, SearchResult, TableColumn, TableRow } from '@wikimedia/codex' + +export const menuItems: MenuItemData[] = [ + { label: 'Edit', value: 'edit', icon: cdxIconEdit }, + { label: 'Delete', value: 'delete', icon: cdxIconTrash }, + { label: 'Add', value: 'add', icon: cdxIconAdd }, +] + +export const selectOptions = [ + { label: 'Option A', value: 'a' }, + { label: 'Option B', value: 'b' }, + { label: 'Option C', value: 'c' }, +] + +export const lookupResults: MenuItemData[] = [ + { label: 'Albert Einstein', value: 'Albert Einstein' }, + { label: 'Albert Camus', value: 'Albert Camus' }, + { label: 'Alberta', value: 'Alberta' }, +] + +export const chipItems: ChipInputItem[] = [ + { value: 'alpha' }, + { value: 'beta' }, +] + +export const tableColumns: TableColumn[] = [ + { id: 'title', label: 'Title', sortable: true }, + { id: 'status', label: 'Status', sortable: true }, + { id: 'views', label: 'Views', sortable: true }, +] + +export const tableRows: TableRow[] = [ + { id: '1', title: 'Mont Blanc', status: 'Published', views: 1200 }, + { id: '2', title: 'Lake Geneva', status: 'Draft', views: 340 }, + { id: '3', title: 'Rhine', status: 'Published', views: 890 }, +] + +const wetLegImageUrl = `${import.meta.env.BASE_URL}images/wet-leg-o2-infobox.jpg` + +export const thumbnailUrl = wetLegImageUrl + +export const imageUrl = wetLegImageUrl + +export const typeaheadSearchCatalog: SearchResult[] = [ + { + value: 'Albert Einstein', + label: 'Albert Einstein', + description: 'German-born theoretical physicist', + url: 'https://en.wikipedia.org/wiki/Albert_Einstein', + thumbnail: { url: thumbnailUrl, width: 40, height: 40 }, + }, + { + value: 'Albert Camus', + label: 'Albert Camus', + description: 'French philosopher and author', + url: 'https://en.wikipedia.org/wiki/Albert_Camus', + }, + { + value: 'Alberta', + label: 'Alberta', + description: 'Province of Canada', + url: 'https://en.wikipedia.org/wiki/Alberta', + }, + { + value: 'Albert, Prince Consort', + label: 'Albert, Prince Consort', + description: 'Consort of Queen Victoria', + url: 'https://en.wikipedia.org/wiki/Albert,_Prince_Consort', + }, + { + value: 'Albrecht Dürer', + label: 'Albrecht Dürer', + description: 'German painter and printmaker', + url: 'https://en.wikipedia.org/wiki/Albrecht_D%C3%BCrer', + }, + { + value: 'Alberta (disambiguation)', + label: 'Alberta (disambiguation)', + description: 'Topics referred to by the same term', + url: 'https://en.wikipedia.org/wiki/Alberta_(disambiguation)', + }, +] + +export function filterTypeaheadSearchResults(query: string): SearchResult[] { + const trimmed = query.trim().toLowerCase() + if (!trimmed.length) return [] + + return typeaheadSearchCatalog.filter((result) => { + const label = String(result.label ?? result.value).toLowerCase() + const description = result.description?.toLowerCase() ?? '' + return label.includes(trimmed) || description.includes(trimmed) + }) +} + +export const buttonGroupItems = [ + { value: 'edit', label: 'Edit' }, + { value: 'history', label: 'History' }, + { value: 'watch', label: 'Watch' }, +] + +export const buttonGroupLongItems = [ + { value: 'all', label: 'All' }, + { value: 'newcomers', label: 'Newcomers' }, + { value: 'mobile', label: 'Mobile edits' }, + { value: 'needs-review', label: 'Needs review' }, +] + +export const toggleGroupItems = [ + { value: 'left', label: 'Left' }, + { value: 'center', label: 'Center' }, + { value: 'right', label: 'Right' }, +] diff --git a/src/prototypes/example-codex-kitchen-sink/lib/palette-colors.ts b/src/prototypes/example-codex-kitchen-sink/lib/palette-colors.ts new file mode 100644 index 00000000..a5a22e05 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/lib/palette-colors.ts @@ -0,0 +1,183 @@ +export interface PaletteColor { + name: string + value: string +} + +export interface PaletteGroup { + family: string + colors: PaletteColor[] +} + +/** Raw Codex palette from https://doc.wikimedia.org/codex/latest/style-guide/colors.html */ +export const codexPaletteGroups: PaletteGroup[] = [ + { + family: 'White', + colors: [{ name: 'white', value: '#fff' }], + }, + { + family: 'Black', + colors: [{ name: 'black', value: '#000' }], + }, + { + family: 'Gray', + colors: [ + { name: 'gray50', value: '#f8f9fa' }, + { name: 'gray100', value: '#eaecf0' }, + { name: 'gray200', value: '#dadde3' }, + { name: 'gray300', value: '#c8ccd1' }, + { name: 'gray400', value: '#a2a9b1' }, + { name: 'gray500', value: '#72777d' }, + { name: 'gray600', value: '#54595d' }, + { name: 'gray700', value: '#404244' }, + { name: 'gray800', value: '#27292d' }, + { name: 'gray900', value: '#202122' }, + { name: 'gray1000', value: '#101418' }, + ], + }, + { + family: 'Red', + colors: [ + { name: 'red50', value: '#ffe9e5' }, + { name: 'red100', value: '#ffdad3' }, + { name: 'red200', value: '#ffc8bd' }, + { name: 'red300', value: '#fea898' }, + { name: 'red400', value: '#fd7865' }, + { name: 'red500', value: '#f54739' }, + { name: 'red600', value: '#d74032' }, + { name: 'red700', value: '#bf3c2c' }, + { name: 'red800', value: '#9f3526' }, + { name: 'red900', value: '#612419' }, + { name: 'red1000', value: '#3c1a13' }, + ], + }, + { + family: 'Orange', + colors: [ + { name: 'orange50', value: '#ffead4' }, + { name: 'orange100', value: '#ffdcb8' }, + { name: 'orange200', value: '#ffc894' }, + { name: 'orange300', value: '#ffa758' }, + { name: 'orange400', value: '#f97f26' }, + { name: 'orange500', value: '#d46926' }, + { name: 'orange600', value: '#bb5c26' }, + { name: 'orange700', value: '#a95226' }, + { name: 'orange800', value: '#8e4424' }, + { name: 'orange900', value: '#572c19' }, + { name: 'orange1000', value: '#361d13' }, + ], + }, + { + family: 'Yellow', + colors: [ + { name: 'yellow50', value: '#fdf2d5' }, + { name: 'yellow100', value: '#ffe49c' }, + { name: 'yellow200', value: '#ffcf4f' }, + { name: 'yellow300', value: '#edb537' }, + { name: 'yellow400', value: '#ca982e' }, + { name: 'yellow500', value: '#ab7f2a' }, + { name: 'yellow600', value: '#987027' }, + { name: 'yellow700', value: '#886425' }, + { name: 'yellow800', value: '#735421' }, + { name: 'yellow900', value: '#453217' }, + { name: 'yellow1000', value: '#2d2212' }, + ], + }, + { + family: 'Lime', + colors: [ + { name: 'lime50', value: '#e3f2e4' }, + { name: 'lime100', value: '#d1e9d2' }, + { name: 'lime200', value: '#b9debc' }, + { name: 'lime300', value: '#94cb9a' }, + { name: 'lime400', value: '#5db26c' }, + { name: 'lime500', value: '#259948' }, + { name: 'lime600', value: '#1f893f' }, + { name: 'lime700', value: '#1f7a39' }, + { name: 'lime800', value: '#1f6631' }, + { name: 'lime900', value: '#183f20' }, + { name: 'lime1000', value: '#142817' }, + ], + }, + { + family: 'Green', + colors: [ + { name: 'green50', value: '#dff2eb' }, + { name: 'green100', value: '#c9eadd' }, + { name: 'green200', value: '#aedfcd' }, + { name: 'green300', value: '#80cdb3' }, + { name: 'green400', value: '#2cb491' }, + { name: 'green500', value: '#099979' }, + { name: 'green600', value: '#14876b' }, + { name: 'green700', value: '#177860' }, + { name: 'green800', value: '#196551' }, + { name: 'green900', value: '#153d31' }, + { name: 'green1000', value: '#132821' }, + ], + }, + { + family: 'Blue', + colors: [ + { name: 'blue50', value: '#e8eeff' }, + { name: 'blue100', value: '#d9e2ff' }, + { name: 'blue200', value: '#b6d4fb' }, + { name: 'blue300', value: '#a6bbf5' }, + { name: 'blue400', value: '#88a3e8' }, + { name: 'blue500', value: '#6485d1' }, + { name: 'blue600', value: '#4b77d6' }, + { name: 'blue700', value: '#36c' }, + { name: 'blue800', value: '#3056a9' }, + { name: 'blue900', value: '#233566' }, + { name: 'blue1000', value: '#1b223d' }, + ], + }, + { + family: 'Purple', + colors: [ + { name: 'purple50', value: '#f0ecf6' }, + { name: 'purple100', value: '#e6e0f0' }, + { name: 'purple200', value: '#d9d0e9' }, + { name: 'purple300', value: '#c5b9dd' }, + { name: 'purple400', value: '#a799cd' }, + { name: 'purple500', value: '#8d7ebd' }, + { name: 'purple600', value: '#7a6db7' }, + { name: 'purple700', value: '#6a60b0' }, + { name: 'purple800', value: '#534fa3' }, + { name: 'purple900', value: '#353262' }, + { name: 'purple1000', value: '#23203b' }, + ], + }, + { + family: 'Pink', + colors: [ + { name: 'pink50', value: '#f5ebf2' }, + { name: 'pink100', value: '#eedeea' }, + { name: 'pink200', value: '#e6cede' }, + { name: 'pink300', value: '#d9b4cd' }, + { name: 'pink400', value: '#c690b4' }, + { name: 'pink500', value: '#b5739e' }, + { name: 'pink600', value: '#ac5c90' }, + { name: 'pink700', value: '#9b527f' }, + { name: 'pink800', value: '#82456a' }, + { name: 'pink900', value: '#4e2c40' }, + { name: 'pink1000', value: '#311e28' }, + ], + }, + { + family: 'Maroon', + colors: [ + { name: 'maroon50', value: '#f6ebeb' }, + { name: 'maroon100', value: '#f0dedd' }, + { name: 'maroon200', value: '#e8cecd' }, + { name: 'maroon300', value: '#dcb5b3' }, + { name: 'maroon400', value: '#c99391' }, + { name: 'maroon500', value: '#b57775' }, + { name: 'maroon600', value: '#ac6262' }, + { name: 'maroon700', value: '#9f5555' }, + { name: 'maroon800', value: '#854848' }, + { name: 'maroon900', value: '#512e2e' }, + { name: 'maroon1000', value: '#321f1e' }, + ], + }, +] + +export const codexPaletteColors = codexPaletteGroups.flatMap((group) => group.colors) diff --git a/src/prototypes/example-codex-kitchen-sink/lib/parse-tokens.ts b/src/prototypes/example-codex-kitchen-sink/lib/parse-tokens.ts new file mode 100644 index 00000000..52855da0 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/lib/parse-tokens.ts @@ -0,0 +1,1025 @@ +export type TokenKind = + | 'color-text' + | 'color-bg' + | 'color-border' + | 'color-accent' + | 'spacing' + | 'radius' + | 'font-size' + | 'font-weight' + | 'font-family' + | 'line-height' + | 'text-decoration' + | 'text-overflow' + | 'shadow' + | 'opacity' + | 'size' + | 'generic' + +export type TokenFamily = + | 'Color' + | 'Spacing' + | 'Typography' + | 'Border' + | 'Shadow' + | 'Sizing' + | 'Opacity' + | 'Transition' + | 'Z-index' + | 'Other' + +export interface TokenEntry { + name: string + value: string + category: string + kind: TokenKind + family: TokenFamily + deprecated: boolean +} + +export interface TokenFamilyGroup { + family: TokenFamily + categories: { category: string; tokens: TokenEntry[] }[] +} + +export function inferTokenKind(name: string): TokenKind { + if (name.startsWith('--background-color-')) return 'color-bg' + if (name.startsWith('--border-color-')) return 'color-border' + if (name.startsWith('--accent-color-')) return 'color-accent' + if (name.startsWith('--color-')) return 'color-text' + if (name.startsWith('--spacing-')) return 'spacing' + if (name.startsWith('--border-radius-')) return 'radius' + if (name.startsWith('--font-size-')) return 'font-size' + if (name.startsWith('--font-weight-')) return 'font-weight' + if (name.startsWith('--font-family-')) return 'font-family' + if (name.startsWith('--line-height-')) return 'line-height' + if (name.startsWith('--text-decoration-')) return 'text-decoration' + if (name.startsWith('--text-overflow-')) return 'text-overflow' + if (name.startsWith('--box-shadow-')) return 'shadow' + if (name.startsWith('--opacity-')) return 'opacity' + if ( + name.startsWith('--size-') || + name.startsWith('--min-size-') || + name.startsWith('--max-size-') || + name.startsWith('--min-width-') || + name.startsWith('--max-width-') || + name.startsWith('--width-') || + name.startsWith('--height-') + ) { + return 'size' + } + return 'generic' +} + +export function inferTokenFamily(name: string): TokenFamily { + if ( + name.startsWith('--color-') || + name.startsWith('--background-color-') || + name.startsWith('--border-color-') || + name.startsWith('--accent-color-') + ) { + return 'Color' + } + if (name.startsWith('--spacing-')) return 'Spacing' + if ( + name.startsWith('--font-') || + name.startsWith('--line-height-') || + name.startsWith('--letter-spacing-') || + name.startsWith('--text-decoration-') || + name.startsWith('--text-overflow-') + ) { + return 'Typography' + } + if ( + name.startsWith('--border-radius-') || + name.startsWith('--border-width-') || + name.startsWith('--border-style-') || + name.startsWith('--position-offset-') + ) { + return 'Border' + } + if (name.startsWith('--box-shadow-')) return 'Shadow' + if (name.startsWith('--opacity-')) return 'Opacity' + if (name.startsWith('--transition-') || name.startsWith('--animation-')) return 'Transition' + if (name.startsWith('--z-index-')) return 'Z-index' + if ( + name.startsWith('--size-') || + name.startsWith('--min-') || + name.startsWith('--max-') || + name.startsWith('--width-') || + name.startsWith('--height-') + ) { + return 'Sizing' + } + return 'Other' +} + +export function inferTokenCategory(name: string): string { + if (name.startsWith('--background-color-')) return 'Background colors' + if (name.startsWith('--border-color-')) return 'Border colors' + if (name.startsWith('--color-')) return 'Text colors' + if (name.startsWith('--spacing-')) return 'Spacing' + if ( + name.startsWith('--border-radius-') || + name.startsWith('--border-width-') || + name.startsWith('--border-style-') || + name.startsWith('--position-offset-') + ) { + return 'Border' + } + if (name.startsWith('--font-') || name.startsWith('--line-height-') || name.startsWith('--letter-spacing-')) { + return 'Typography' + } + if (name.startsWith('--box-shadow-')) return 'Shadow' + if (name.startsWith('--opacity-')) return 'Opacity' + if (name.startsWith('--size-') || name.startsWith('--min-') || name.startsWith('--max-') || name.startsWith('--width-') || name.startsWith('--height-')) { + return 'Sizing' + } + if (name.startsWith('--transition-')) return 'Transition' + if (name.startsWith('--animation-')) return 'Animation' + if (name.startsWith('--z-index-')) return 'Z-index' + if (name.startsWith('--cursor-')) return 'Cursor' + if (name.startsWith('--filter-')) return 'Filter' + if (name.startsWith('--outline-')) return 'Outline' + if (name.startsWith('--mix-blend-mode-')) return 'Blend mode' + if (name.startsWith('--text-decoration-')) return 'Text decoration' + if (name.startsWith('--text-overflow-')) return 'Text overflow' + if (name.startsWith('--transform-')) return 'Transform' + if (name.startsWith('--accent-color-')) return 'Accent' + if (name.startsWith('--position-')) return 'Position' + if (name.startsWith('--tab-size-')) return 'Tab size' + return 'Other' +} + +export function sortTokensWithDeprecatedLast( + tokens: TokenEntry[], + compare: (a: TokenEntry, b: TokenEntry) => number = (a, b) => a.name.localeCompare(b.name), +): TokenEntry[] { + return [...tokens].sort((a, b) => { + if (a.deprecated !== b.deprecated) return a.deprecated ? 1 : -1 + return compare(a, b) + }) +} + +function parseDeprecatedTokenNames(cssBlock: string): Set { + const deprecated = new Set() + const re = + /\/\*\s*Warning:\s*the following token name is deprecated[^*]*\*\/\s*\n\s*(--[a-z0-9-]+):/g + let match: RegExpExecArray | null + + while ((match = re.exec(cssBlock)) !== null) { + deprecated.add(match[1]) + } + + return deprecated +} + +export function parseTokensFromCss(css: string): TokenEntry[] { + const rootMatch = css.match(/:root\s*\{([\s\S]*?)\n\}/) + if (!rootMatch) return [] + + const rootBlock = rootMatch[1] + const deprecatedNames = parseDeprecatedTokenNames(rootBlock) + const seen = new Set() + const tokens: TokenEntry[] = [] + const re = /^\s*(--[a-z0-9-]+):\s*([^;]+);/gm + let match: RegExpExecArray | null + + while ((match = re.exec(rootBlock)) !== null) { + const name = match[1] + if (seen.has(name)) continue + seen.add(name) + tokens.push({ + name, + value: match[2].trim(), + category: inferTokenCategory(name), + kind: inferTokenKind(name), + family: inferTokenFamily(name), + deprecated: deprecatedNames.has(name), + }) + } + + return sortTokensWithDeprecatedLast(tokens) +} + +const categoryOrder = [ + 'Text colors', + 'Background colors', + 'Border colors', + 'Spacing', + 'Border', + 'Typography', + 'Shadow', + 'Opacity', + 'Sizing', + 'Transition', + 'Animation', + 'Z-index', + 'Cursor', + 'Filter', + 'Outline', + 'Blend mode', + 'Text decoration', + 'Transform', + 'Accent', + 'Position', + 'Tab size', + 'Other', +] as const + +const familyOrder: TokenFamily[] = [ + 'Color', + 'Spacing', + 'Typography', + 'Border', + 'Shadow', + 'Sizing', + 'Opacity', + 'Transition', + 'Z-index', + 'Other', +] + +function groupByCategory(tokens: TokenEntry[]): { category: string; tokens: TokenEntry[] }[] { + const map = new Map() + for (const token of tokens) { + const list = map.get(token.category) ?? [] + list.push(token) + map.set(token.category, list) + } + + return categoryOrder + .filter((category) => map.has(category)) + .map((category) => ({ + category, + tokens: sortTokensWithDeprecatedLast(map.get(category)!), + })) +} + +export function groupTokensByCategory(tokens: TokenEntry[]): { category: string; tokens: TokenEntry[] }[] { + return groupByCategory(tokens) +} + +export function groupTokensByFamily( + tokens: TokenEntry[], + options: { exclude?: TokenFamily[] } = {}, +): TokenFamilyGroup[] { + const excluded = new Set(options.exclude ?? []) + const byFamily = new Map() + + for (const token of tokens) { + if (excluded.has(token.family)) continue + const list = byFamily.get(token.family) ?? [] + list.push(token) + byFamily.set(token.family, list) + } + + return familyOrder + .filter((family) => !excluded.has(family) && byFamily.has(family)) + .map((family) => ({ + family, + categories: groupByCategory(byFamily.get(family)!), + })) +} + +export function getTokensForFamily(tokens: TokenEntry[], family: TokenFamily): TokenEntry[] { + return tokens.filter((token) => token.family === family) +} + +export type TypographySubTab = + | 'style' + | 'font' + | 'size' + | 'weight' + | 'line-height' + | 'decoration' + | 'overflow' + +const typographySubTabOrder: TypographySubTab[] = [ + 'style', + 'font', + 'size', + 'weight', + 'line-height', + 'decoration', + 'overflow', +] + +const typographySubTabLabels: Record = { + style: 'Style', + font: 'Font', + size: 'Size', + weight: 'Weight', + 'line-height': 'Line height', + decoration: 'Decoration', + overflow: 'Overflow', +} + +export const typographySubTabs = typographySubTabOrder.map((id) => ({ + id, + label: typographySubTabLabels[id], +})) + +export function getTypographyTokensForSubTab( + tokens: TokenEntry[], + subTab: Exclude, +): TokenEntry[] { + const kindBySubTab: Record, TokenKind> = { + font: 'font-family', + size: 'font-size', + weight: 'font-weight', + 'line-height': 'line-height', + decoration: 'text-decoration', + overflow: 'text-overflow', + } + + const filtered = getTokensForFamily(tokens, 'Typography').filter( + (token) => token.kind === kindBySubTab[subTab], + ) + + if (subTab === 'size') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => parseFontSizeValue(a.value) - parseFontSizeValue(b.value)) + } + + if (subTab === 'weight') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => parseFontWeightValue(a.value) - parseFontWeightValue(b.value)) + } + + if (subTab === 'line-height') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => lineHeightSortIndex(a.name) - lineHeightSortIndex(b.name)) + } + + if (subTab === 'decoration') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => textDecorationSortIndex(a.name) - textDecorationSortIndex(b.name)) + } + + if (subTab === 'overflow') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => textOverflowSortIndex(a.name) - textOverflowSortIndex(b.name)) + } + + return sortTokensWithDeprecatedLast(filtered) +} + +const lineHeightScaleOrder = [ + 'x-small', + 'small', + 'medium', + 'content', + 'large', + 'x-large', + 'xx-large', + 'xxx-large', +] as const + +function lineHeightSortIndex(name: string): number { + const suffix = name.slice('--line-height-'.length) + const index = lineHeightScaleOrder.indexOf(suffix as (typeof lineHeightScaleOrder)[number]) + return index === -1 ? lineHeightScaleOrder.length : index +} + +const textDecorationOrder = ['none', 'underline', 'line-through'] as const + +function textDecorationSortIndex(name: string): number { + const suffix = name.slice('--text-decoration-'.length) + const index = textDecorationOrder.indexOf(suffix as (typeof textDecorationOrder)[number]) + return index === -1 ? textDecorationOrder.length : index +} + +const textOverflowOrder = ['clip', 'ellipsis'] as const + +function textOverflowSortIndex(name: string): number { + const suffix = name.slice('--text-overflow-'.length) + const index = textOverflowOrder.indexOf(suffix as (typeof textOverflowOrder)[number]) + return index === -1 ? textOverflowOrder.length : index +} + +function parseFontSizeValue(value: string): number { + const match = value.match(/^([\d.]+)rem$/) + return match ? parseFloat(match[1]) : 0 +} + +function parseFontWeightValue(value: string): number { + return parseFloat(value) || 0 +} + +export type ColorSubTab = 'text' | 'background' | 'border' | 'accent' | 'palette' + +const colorSubTabOrder: ColorSubTab[] = ['text', 'background', 'border', 'accent', 'palette'] + +const colorSubTabLabels: Record = { + text: 'Text', + background: 'Background', + border: 'Border', + accent: 'Accent', + palette: 'Palette', +} + +export const colorSubTabs = colorSubTabOrder.map((id) => ({ + id, + label: colorSubTabLabels[id], +})) + +export function getColorTokensForSubTab(tokens: TokenEntry[], subTab: ColorSubTab): TokenEntry[] { + if (subTab === 'palette') return [] + + const kindBySubTab: Record, TokenKind> = { + text: 'color-text', + background: 'color-bg', + border: 'color-border', + accent: 'color-accent', + } + + return sortTokensWithDeprecatedLast( + getTokensForFamily(tokens, 'Color').filter((token) => token.kind === kindBySubTab[subTab]), + ) +} + +export type TokenSection = 'layout' | 'appearance' | 'animation' + +export type LayoutSubTab = + | 'spacing' + | 'size' + | 'breakpoint' + | 'z-index' + | 'box-sizing' + +export type AppearanceSubTab = 'border' | 'box-shadow' | 'cursor' | 'opacity' | 'outline' + +export type AnimationSubTab = 'animation' | 'transition' + +export type TokenSubTab = LayoutSubTab | AppearanceSubTab | AnimationSubTab + +const layoutSubTabOrder: LayoutSubTab[] = [ + 'spacing', + 'size', + 'breakpoint', + 'z-index', + 'box-sizing', +] + +const appearanceSubTabOrder: AppearanceSubTab[] = [ + 'border', + 'box-shadow', + 'cursor', + 'opacity', + 'outline', +] + +const animationSubTabOrder: AnimationSubTab[] = ['animation', 'transition'] + +const layoutSubTabLabels: Record = { + spacing: 'Spacing', + size: 'Size', + breakpoint: 'Breakpoint', + 'z-index': 'Z-index', + 'box-sizing': 'Box-sizing', +} + +const appearanceSubTabLabels: Record = { + border: 'Border', + 'box-shadow': 'Box-shadow', + cursor: 'Cursor', + opacity: 'Opacity', + outline: 'Outline', +} + +const animationSubTabLabels: Record = { + animation: 'Animation', + transition: 'Transition', +} + +export const tokenSectionTabs = [ + { + id: 'layout' as const, + label: 'Layout', + subTabs: layoutSubTabOrder.map((id) => ({ id, label: layoutSubTabLabels[id] })), + }, + { + id: 'appearance' as const, + label: 'Appearance', + subTabs: appearanceSubTabOrder.map((id) => ({ id, label: appearanceSubTabLabels[id] })), + }, + { + id: 'animation' as const, + label: 'Animation', + subTabs: animationSubTabOrder.map((id) => ({ id, label: animationSubTabLabels[id] })), + }, +] + +const borderShorthandNames = new Set([ + '--border-base', + '--border-subtle', + '--border-progressive', + '--border-destructive', + '--border-transparent', +]) + +export type BorderTokenGroup = 'Shorthand' | 'Radius' | 'Style' | 'Width' | 'Offset' + +export function isPositionOffsetToken(name: string): boolean { + return name.startsWith('--position-offset-') +} + +export type TransitionTokenGroup = 'Property' | 'Timing' | 'Duration' + +export type AnimationTokenGroup = 'Timing' | 'Iteration' | 'Duration' | 'Delay' | 'Transform' + +export function getBorderTokenGroup(name: string): BorderTokenGroup { + if (borderShorthandNames.has(name)) return 'Shorthand' + if (name.startsWith('--border-radius-')) return 'Radius' + if (name.startsWith('--border-style-')) return 'Style' + if (isPositionOffsetToken(name)) return 'Offset' + return 'Width' +} + +export function getTransitionTokenGroup(name: string): TransitionTokenGroup { + if (name.startsWith('--transition-property-')) return 'Property' + if (name.startsWith('--transition-timing-function-')) return 'Timing' + return 'Duration' +} + +export function getAnimationTokenGroup(name: string): AnimationTokenGroup { + if (name.startsWith('--animation-timing-function-')) return 'Timing' + if (name.startsWith('--animation-iteration-count-')) return 'Iteration' + if (name.startsWith('--animation-duration-')) return 'Duration' + if (name.startsWith('--animation-delay-')) return 'Delay' + return 'Transform' +} + +const borderTokenOrder = [ + '--border-base', + '--border-subtle', + '--border-progressive', + '--border-destructive', + '--border-transparent', + '--border-radius-sharp', + '--border-radius-base', + '--border-radius-pill', + '--border-radius-circle', + '--border-style-base', + '--border-style-dashed', + '--border-width-base', + '--border-width-thick', + '--border-width-input-radio--checked', + '--position-offset-border-width-base', +] as const + +export type BoxShadowTokenGroup = 'Shorthand' | 'Color' | 'Inset' | 'Outset' | 'Deprecated' + +const deprecatedBoxShadowTokens = new Set([ + '--box-shadow-drop-small', + '--box-shadow-drop-medium', + '--box-shadow-drop-xx-large', +]) + +export function getBoxShadowTokenGroup(name: string): BoxShadowTokenGroup { + if (name.startsWith('--box-shadow-color-')) return 'Color' + if (name.startsWith('--box-shadow-inset-')) return 'Inset' + if (name.startsWith('--box-shadow-outset-')) return 'Outset' + if (deprecatedBoxShadowTokens.has(name)) return 'Deprecated' + return 'Shorthand' +} + +const boxShadowTokenOrder = [ + '--box-shadow-small', + '--box-shadow-small-top', + '--box-shadow-small-bottom', + '--box-shadow-medium', + '--box-shadow-large', + '--box-shadow-inset-small', + '--box-shadow-inset-medium', + '--box-shadow-inset-medium-vertical', + '--box-shadow-outset-small', + '--box-shadow-outset-small-top', + '--box-shadow-outset-small-bottom', + '--box-shadow-outset-small-start', + '--box-shadow-outset-medium-below', + '--box-shadow-outset-medium-around', + '--box-shadow-outset-large-below', + '--box-shadow-outset-large-around', + '--box-shadow-color-alpha-base', + '--box-shadow-color-base', + '--box-shadow-color-destructive--focus', + '--box-shadow-color-inverted', + '--box-shadow-color-progressive--active', + '--box-shadow-color-progressive--focus', + '--box-shadow-color-progressive-selected', + '--box-shadow-color-progressive-selected--active', + '--box-shadow-color-progressive-selected--hover', + '--box-shadow-color-transparent', + '--box-shadow-drop-small', + '--box-shadow-drop-medium', + '--box-shadow-drop-xx-large', +] as const + +const cursorTokenOrder = [ + '--cursor-base', + '--cursor-base--disabled', + '--cursor-base--hover', + '--cursor-grab', + '--cursor-grabbing', + '--cursor-help', + '--cursor-move', + '--cursor-not-allowed', + '--cursor-resize-nesw', + '--cursor-resize-nwse', + '--cursor-text', + '--cursor-zoom-in', + '--cursor-zoom-out', +] as const + +const opacityTokenOrder = [ + '--opacity-base', + '--opacity-medium', + '--opacity-low', + '--opacity-transparent', + '--opacity-icon-base', + '--opacity-icon-base--hover', + '--opacity-icon-base--selected', + '--opacity-icon-base--disabled', + '--opacity-icon-placeholder', + '--opacity-icon-subtle', +] as const + +const outlineTokenOrder = [ + '--outline-base--focus', + '--outline-color-progressive--focus', +] as const + +const transitionTokenOrder = [ + '--transition-property-base', + '--transition-property-fade', + '--transition-property-icon', + '--transition-property-icon-css-only', + '--transition-property-toast', + '--transition-property-toggle-switch-grip', + '--transition-timing-function-system', + '--transition-timing-function-user', + '--transition-duration-base', + '--transition-duration-medium', +] as const + +const animationTokenOrder = [ + '--animation-timing-function-base', + '--animation-timing-function-bouncing', + '--animation-iteration-count-base', + '--animation-duration-fast', + '--animation-duration-medium', + '--animation-duration-slow', + '--animation-delay-none', + '--animation-delay-medium', + '--animation-delay-slow', + '--transform-checkbox-tick--checked', + '--transform-progress-indicator-spinner-start', + '--transform-progress-indicator-spinner-end', +] as const + +const breakpointTokenOrder = [ + '--min-width-breakpoint-mobile', + '--min-width-breakpoint-tablet', + '--min-width-breakpoint-desktop', + '--min-width-breakpoint-desktop-wide', + '--max-width-breakpoint-mobile', + '--max-width-breakpoint-tablet', + '--max-width-breakpoint-desktop', +] as const + +export function inferTokenSubTab(name: string): TokenSubTab | null { + if (name.startsWith('--spacing-')) return 'spacing' + if (name.startsWith('--box-sizing-')) return 'box-sizing' + if (name.startsWith('--z-index-')) return 'z-index' + if (name.includes('breakpoint')) return 'breakpoint' + if (name.startsWith('--background-position-') || name.startsWith('--background-size-')) return 'size' + if (name.startsWith('--position-offset-')) return 'border' + + if ( + name.startsWith('--size-') || + name.startsWith('--min-size-') || + name.startsWith('--max-size-') || + name.startsWith('--min-width-') || + name.startsWith('--max-width-') || + name.startsWith('--width-') || + name.startsWith('--height-') || + name.startsWith('--min-height-') || + name.startsWith('--max-height-') || + name.startsWith('--background-size-') || + name.startsWith('--tab-size-') + ) { + return 'size' + } + + if ( + name.startsWith('--border-radius-') || + name.startsWith('--border-width-') || + name.startsWith('--border-style-') || + borderShorthandNames.has(name) + ) { + return 'border' + } + + if (name.startsWith('--box-shadow-')) return 'box-shadow' + if (name.startsWith('--cursor-')) return 'cursor' + if (name.startsWith('--opacity-')) return 'opacity' + if (name.startsWith('--outline-')) return 'outline' + + if (name.startsWith('--transition-')) return 'transition' + if (name.startsWith('--animation-') || name.startsWith('--transform-')) return 'animation' + + return null +} + +export function inferTokenSection(subTab: TokenSubTab): TokenSection { + if (layoutSubTabOrder.includes(subTab as LayoutSubTab)) return 'layout' + if (appearanceSubTabOrder.includes(subTab as AppearanceSubTab)) return 'appearance' + return 'animation' +} + +export function getGeneralTokens(tokens: TokenEntry[]): TokenEntry[] { + return tokens.filter((token) => token.family !== 'Color' && token.family !== 'Typography') +} + +const spacingTokenOrder = [ + '--spacing-0', + '--spacing-6', + '--spacing-12', + '--spacing-25', + '--spacing-30', + '--spacing-35', + '--spacing-50', + '--spacing-65', + '--spacing-75', + '--spacing-100', + '--spacing-125', + '--spacing-150', + '--spacing-200', + '--spacing-250', + '--spacing-300', + '--spacing-400', + '--spacing-half', + '--spacing-full', + '--spacing-horizontal-button', + '--spacing-horizontal-button-large', + '--spacing-horizontal-button-small', + '--spacing-horizontal-button-icon-only', + '--spacing-horizontal-button-small-icon-only', + '--spacing-horizontal-input-text-two-end-icons', + '--spacing-start-typeahead-search-figure', + '--spacing-start-typeahead-search-icon', + '--spacing-typeahead-search-focus-addition', + '--spacing-toggle-switch-grip-start', + '--spacing-toggle-switch-grip-end', +] as const + +const sizeTokenOrder = [ + '--size-0', + '--size-6', + '--size-12', + '--size-25', + '--size-50', + '--size-75', + '--size-100', + '--size-125', + '--size-150', + '--size-200', + '--size-250', + '--size-275', + '--size-300', + '--size-400', + '--size-800', + '--size-1200', + '--size-1600', + '--size-2400', + '--size-2800', + '--size-3200', + '--size-4000', + '--size-5600', + '--size-absolute-1', + '--size-absolute-9999', + '--size-viewport-width-full', + '--size-viewport-height-full', + '--size-content-min', + '--size-content-fit', + '--size-content-max', + '--size-third', + '--size-half', + '--size-full', + '--size-double', + '--size-search-figure', + '--size-icon-x-small', + '--size-icon-small', + '--size-icon-medium', + '--size-toggle-switch-grip', + '--min-size-interactive-pointer', + '--min-size-interactive-touch', + '--min-size-icon-x-small', + '--min-size-icon-small', + '--min-size-icon-medium', + '--min-size-search-figure', + '--min-size-input-binary', + '--min-size-input-chip-clear-button', + '--min-size-toggle-switch-grip', + '--min-width-medium', + '--min-width-breakpoint-mobile', + '--min-width-breakpoint-tablet', + '--min-width-breakpoint-desktop', + '--min-width-breakpoint-desktop-wide', + '--min-width-toggle-switch', + '--max-width-base', + '--max-width-breakpoint-mobile', + '--max-width-breakpoint-tablet', + '--max-width-breakpoint-desktop', + '--max-width-button', + '--min-height-text-area', + '--min-height-table-header', + '--min-height-table-footer', + '--min-height-toggle-switch', + '--max-height-chip', + '--width-toggle-switch', + '--height-toggle-switch', + '--background-position-base', + '--background-size-search-figure', + '--tab-size-base', +] as const + +function manualTokenSortIndex(name: string, order: readonly string[]): number { + const index = order.indexOf(name) + return index === -1 ? order.length : index +} + +export type SpacingTokenGroup = 'Scale' | 'Positioning' | 'Components' + +export function getSpacingTokenGroup(name: string): SpacingTokenGroup { + if (name === '--spacing-half' || name === '--spacing-full') return 'Positioning' + if (/^--spacing-\d/.test(name)) return 'Scale' + return 'Components' +} + +export type SizeTokenGroup = + | 'Scale' + | 'Absolute' + | 'Viewport' + | 'Content sizing' + | 'Fractions' + | 'Component sizes' + | 'Minimum sizes' + | 'Minimum widths' + | 'Maximum widths' + | 'Minimum heights' + | 'Maximum heights' + | 'Component dimensions' + | 'Other' + +export type BreakpointTokenGroup = 'Minimum width' | 'Maximum width' + +export function getBreakpointTokenGroup(name: string): BreakpointTokenGroup { + return name.startsWith('--max-width-breakpoint-') ? 'Maximum width' : 'Minimum width' +} + +export function getSizeTokenGroup(name: string): SizeTokenGroup { + if (/^--size-\d/.test(name)) return 'Scale' + if (name.startsWith('--size-absolute-')) return 'Absolute' + if (name.startsWith('--size-viewport-')) return 'Viewport' + if (name.startsWith('--size-content-')) return 'Content sizing' + if ( + name === '--size-third' || + name === '--size-half' || + name === '--size-full' || + name === '--size-double' + ) { + return 'Fractions' + } + if ( + name.startsWith('--size-search-') || + name.startsWith('--size-icon-') || + name.startsWith('--size-toggle-') + ) { + return 'Component sizes' + } + if (name.startsWith('--min-size-')) return 'Minimum sizes' + if (name.startsWith('--min-width-')) return 'Minimum widths' + if (name.startsWith('--max-width-')) return 'Maximum widths' + if (name.startsWith('--min-height-')) return 'Minimum heights' + if (name.startsWith('--max-height-')) return 'Maximum heights' + if (name.startsWith('--width-') || name.startsWith('--height-')) return 'Component dimensions' + if (name.startsWith('--background-position-') || name.startsWith('--background-size-')) return 'Background' + return 'Other' +} + +export function isBackgroundPositionToken(name: string): boolean { + return name.startsWith('--background-position-') +} + +export function isBackgroundSizeToken(name: string): boolean { + return name.startsWith('--background-size-') +} + +export type ZIndexTokenGroup = 'Layout' | 'Stacking' + +export function getZIndexTokenGroup(name: string): ZIndexTokenGroup { + return name.startsWith('--z-index-stacking-') ? 'Stacking' : 'Layout' +} + +export function parseZIndexValue(value: string): number { + return Number.parseInt(value.trim(), 10) +} + +export function usesDimensionBarDemo(token: TokenEntry): boolean { + const { name, value } = token + if (value === 'none' || value === 'cover') return false + if (name.startsWith('--size-content-')) return false + if (name === '--tab-size-base') return false + return true +} + +export function parsePlainPercentage(value: string): number | null { + const match = value.trim().match(/^([\d.]+)%$/) + return match ? parseFloat(match[1]) : null +} + +export function isZeroDimensionToken(token: TokenEntry): boolean { + const value = token.value.trim() + if (value === '0') return true + if (/^0(?:px|rem|em|%|vw|vh|ch|ex)?$/i.test(value)) return true + return parsePlainPercentage(value) === 0 +} + +export function getTokensForTokenSubTab( + tokens: TokenEntry[], + section: TokenSection, + subTab: TokenSubTab, +): TokenEntry[] { + const filtered = getGeneralTokens(tokens).filter((token) => { + const tokenSubTab = inferTokenSubTab(token.name) + return tokenSubTab === subTab && inferTokenSection(tokenSubTab) === section + }) + + if (subTab === 'spacing') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => + manualTokenSortIndex(a.name, spacingTokenOrder) - manualTokenSortIndex(b.name, spacingTokenOrder), + ) + } + + if (subTab === 'size') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => + manualTokenSortIndex(a.name, sizeTokenOrder) - manualTokenSortIndex(b.name, sizeTokenOrder), + ) + } + + if (subTab === 'breakpoint') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => + manualTokenSortIndex(a.name, breakpointTokenOrder) - manualTokenSortIndex(b.name, breakpointTokenOrder), + ) + } + + if (subTab === 'z-index') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => { + const groupOrder = getZIndexTokenGroup(a.name).localeCompare(getZIndexTokenGroup(b.name)) + if (groupOrder !== 0) return groupOrder + return parseZIndexValue(a.value) - parseZIndexValue(b.value) + }) + } + + if (subTab === 'border') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => + manualTokenSortIndex(a.name, borderTokenOrder) - manualTokenSortIndex(b.name, borderTokenOrder), + ) + } + + if (subTab === 'box-shadow') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => + manualTokenSortIndex(a.name, boxShadowTokenOrder) - manualTokenSortIndex(b.name, boxShadowTokenOrder), + ) + } + + if (subTab === 'cursor') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => + manualTokenSortIndex(a.name, cursorTokenOrder) - manualTokenSortIndex(b.name, cursorTokenOrder), + ) + } + + if (subTab === 'opacity') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => + manualTokenSortIndex(a.name, opacityTokenOrder) - manualTokenSortIndex(b.name, opacityTokenOrder), + ) + } + + if (subTab === 'outline') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => + manualTokenSortIndex(a.name, outlineTokenOrder) - manualTokenSortIndex(b.name, outlineTokenOrder), + ) + } + + if (subTab === 'transition') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => + manualTokenSortIndex(a.name, transitionTokenOrder) - manualTokenSortIndex(b.name, transitionTokenOrder), + ) + } + + if (subTab === 'animation') { + return sortTokensWithDeprecatedLast(filtered, (a, b) => + manualTokenSortIndex(a.name, animationTokenOrder) - manualTokenSortIndex(b.name, animationTokenOrder), + ) + } + + return sortTokensWithDeprecatedLast(filtered) +} diff --git a/src/prototypes/example-codex-kitchen-sink/lib/playground-tabs.ts b/src/prototypes/example-codex-kitchen-sink/lib/playground-tabs.ts new file mode 100644 index 00000000..0a3c2dc4 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/lib/playground-tabs.ts @@ -0,0 +1,132 @@ +import { buttonsSubTabs, contentDataSubTabs, feedbackSubTabs, formElementsSubTabs, iconsSubTabs, mediaSubTabs, navigationSubTabs, searchSubTabs } from './component-tabs' +import { + colorSubTabs, + tokenSectionTabs, + typographySubTabs, + type TokenSection, +} from './parse-tokens' + +export const mainTabIds = [ + 'typography', + 'color', + 'tokens-layout', + 'tokens-appearance', + 'tokens-animation', + 'icons', + 'components-buttons', + 'components-form-elements', + 'components-feedback', + 'components-content-data', + 'components-media', + 'components-navigation', + 'components-search', +] as const + +export type MainTabId = (typeof mainTabIds)[number] + +export const tokenMainTabBySection: Record = { + layout: 'tokens-layout', + appearance: 'tokens-appearance', + animation: 'tokens-animation', +} + +const subTabIdsByMain: Partial> = { + typography: typographySubTabs.map((tab) => tab.id), + color: colorSubTabs.map((tab) => tab.id), + icons: iconsSubTabs.map((tab) => tab.id), + 'components-buttons': buttonsSubTabs.map((tab) => tab.id), + 'components-form-elements': formElementsSubTabs.map((tab) => tab.id), + 'components-content-data': contentDataSubTabs.map((tab) => tab.id), + 'components-feedback': feedbackSubTabs.map((tab) => tab.id), + 'components-media': mediaSubTabs.map((tab) => tab.id), + 'components-navigation': navigationSubTabs.map((tab) => tab.id), + 'components-search': searchSubTabs.map((tab) => tab.id), + 'tokens-layout': tokenSectionTabs.find((entry) => entry.id === 'layout')!.subTabs.map((tab) => tab.id), + 'tokens-appearance': tokenSectionTabs + .find((entry) => entry.id === 'appearance')! + .subTabs.map((tab) => tab.id), + 'tokens-animation': tokenSectionTabs + .find((entry) => entry.id === 'animation')! + .subTabs.map((tab) => tab.id), +} + +export const defaultLeafTab = 'typography/style' + +function isMainTabId(value: string): value is MainTabId { + return (mainTabIds as readonly string[]).includes(value) +} + +export function getDefaultLeafTab(main: MainTabId): string { + const subTabs = subTabIdsByMain[main] + return subTabs ? `${main}/${subTabs[0]}` : main +} + +export function parseLeafTab(raw: string): { main: MainTabId; sub?: string } | null { + const slash = raw.indexOf('/') + if (slash === -1) { + if (!isMainTabId(raw)) return null + if (subTabIdsByMain[raw]) return null + return { main: raw } + } + + const main = raw.slice(0, slash) + const sub = raw.slice(slash + 1) + if (!isMainTabId(main)) return null + + const subTabs = subTabIdsByMain[main] + if (!subTabs || !subTabs.includes(sub)) return null + + return { main, sub } +} + +export function isValidLeafTab(value: string): boolean { + return parseLeafTab(value) !== null +} + +export function resolveLeafTab(raw: string | undefined): string { + if (raw && isValidLeafTab(raw)) return raw + + if (raw && isMainTabId(raw) && subTabIdsByMain[raw]) { + return getDefaultLeafTab(raw) + } + + return defaultLeafTab +} + +export function getSubTabForMain( + mainTabId: MainTabId, + subTabMemory: Partial>, +): string { + const remembered = subTabMemory[mainTabId] + const subTabs = subTabIdsByMain[mainTabId] + if (remembered && subTabs?.includes(remembered)) return remembered + return subTabs?.[0] ?? '' +} + +export function getRememberedLeafTab( + main: MainTabId, + subTabMemory: Partial>, +): string { + const subTabs = subTabIdsByMain[main] + return subTabs ? `${main}/${getSubTabForMain(main, subTabMemory)}` : main +} + +export function subTabForMain( + leafTab: string, + mainTabId: MainTabId, + subTabMemory: Partial> = {}, +): string { + const parsed = parseLeafTab(leafTab) + if (parsed?.main === mainTabId && parsed.sub) return parsed.sub + return getSubTabForMain(mainTabId, subTabMemory) +} + +export function rememberSubTabForMain( + subTabMemory: Partial>, + mainTabId: MainTabId, + subTabId: string, +): void { + if (subTabIdsByMain[mainTabId]?.includes(subTabId)) { + subTabMemory[mainTabId] = subTabId + } +} diff --git a/src/prototypes/example-codex-kitchen-sink/lib/use-playground-leaf-tab.ts b/src/prototypes/example-codex-kitchen-sink/lib/use-playground-leaf-tab.ts new file mode 100644 index 00000000..de57425d --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/lib/use-playground-leaf-tab.ts @@ -0,0 +1,112 @@ +import { inject, provide, ref, watch, type InjectionKey, type Ref } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +import { + defaultLeafTab, + getRememberedLeafTab, + parseLeafTab, + rememberSubTabForMain, + resolveLeafTab, + subTabForMain, + type MainTabId, +} from './playground-tabs' +import { readQueryValue } from './use-url-query-param' + +export interface PlaygroundLeafTabContext { + leafTab: Ref + subTabFor: (mainTabId: MainTabId) => string + setSubTab: (mainTabId: MainTabId, subTabId: string) => void +} + +export const playgroundLeafTabKey: InjectionKey = Symbol('playgroundLeafTab') + +export function usePlaygroundLeafTab(): Ref { + const route = useRoute() + const router = useRouter() + + function read(): string { + return resolveLeafTab(readQueryValue(route.query.tab)) + } + + const leafTab = ref(read()) + let syncingFromRoute = false + + watch( + () => route.query.tab, + () => { + const next = read() + if (next === leafTab.value) return + syncingFromRoute = true + leafTab.value = next + syncingFromRoute = false + }, + ) + + watch(leafTab, (value) => { + if (syncingFromRoute) return + + const normalized = value === defaultLeafTab ? undefined : value + const current = readQueryValue(route.query.tab) + if (current === normalized) return + + const query = { ...route.query } + if (normalized === undefined) { + delete query.tab + } else { + query.tab = normalized + } + void router.replace({ query }) + }) + + return leafTab +} + +export function providePlaygroundLeafTab( + leafTab: Ref, +): PlaygroundLeafTabContext & { subTabMemory: Partial> } { + const subTabMemory: Partial> = {} + + function rememberFromPath(path: string): void { + const parsed = parseLeafTab(path) + if (parsed?.sub) rememberSubTabForMain(subTabMemory, parsed.main, parsed.sub) + } + + rememberFromPath(leafTab.value) + + watch(leafTab, rememberFromPath) + + const context: PlaygroundLeafTabContext = { + leafTab, + subTabFor: (mainTabId) => subTabForMain(leafTab.value, mainTabId, subTabMemory), + setSubTab: (mainTabId, subTabId) => { + rememberSubTabForMain(subTabMemory, mainTabId, subTabId) + leafTab.value = `${mainTabId}/${subTabId}` + }, + } + + provide(playgroundLeafTabKey, context) + return { ...context, subTabMemory } +} + +export function usePlaygroundLeafTabContext(): PlaygroundLeafTabContext | undefined { + return inject(playgroundLeafTabKey) +} + +export function syncMainTabWithLeafTab( + activeTab: Ref, + leafTab: Ref, + subTabMemory: Partial>, +): void { + watch(leafTab, (path) => { + const parsed = parseLeafTab(path) + if (parsed && parsed.main !== activeTab.value) { + activeTab.value = parsed.main + } + }) + + watch(activeTab, (main) => { + const parsed = parseLeafTab(leafTab.value) + if (parsed?.main === main) return + leafTab.value = getRememberedLeafTab(main, subTabMemory) + }) +} diff --git a/src/prototypes/example-codex-kitchen-sink/lib/use-url-query-param.ts b/src/prototypes/example-codex-kitchen-sink/lib/use-url-query-param.ts new file mode 100644 index 00000000..607505cb --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/lib/use-url-query-param.ts @@ -0,0 +1,57 @@ +import { ref, watch, type Ref } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +function readQueryValue(raw: unknown): string | undefined { + if (typeof raw === 'string') return raw + if (Array.isArray(raw) && typeof raw[0] === 'string') return raw[0] + return undefined +} + +export { readQueryValue } + +export function useUrlQueryParam( + key: string, + defaultValue: T, + isValid: (value: string) => value is T, +): Ref { + const route = useRoute() + const router = useRouter() + + function parse(): T { + const value = readQueryValue(route.query[key]) + if (value && isValid(value)) return value + return defaultValue + } + + const param = ref(parse()) as Ref + let syncingFromRoute = false + + watch( + () => route.query[key], + () => { + const next = parse() + if (next === param.value) return + syncingFromRoute = true + param.value = next + syncingFromRoute = false + }, + ) + + watch(param, (value) => { + if (syncingFromRoute) return + + const normalized = value === defaultValue ? undefined : value + const current = readQueryValue(route.query[key]) + if (current === normalized) return + + const query = { ...route.query } + if (normalized === undefined) { + delete query[key] + } else { + query[key] = normalized + } + void router.replace({ query }) + }) + + return param +} diff --git a/src/prototypes/example-codex-kitchen-sink/playground/AnimationTokenList.vue b/src/prototypes/example-codex-kitchen-sink/playground/AnimationTokenList.vue new file mode 100644 index 00000000..3762cae1 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/AnimationTokenList.vue @@ -0,0 +1,416 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/BorderTokenList.vue b/src/prototypes/example-codex-kitchen-sink/playground/BorderTokenList.vue new file mode 100644 index 00000000..373e00cb --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/BorderTokenList.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/BoxShadowTokenList.vue b/src/prototypes/example-codex-kitchen-sink/playground/BoxShadowTokenList.vue new file mode 100644 index 00000000..ccdb6ea9 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/BoxShadowTokenList.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/ColorPaletteList.vue b/src/prototypes/example-codex-kitchen-sink/playground/ColorPaletteList.vue new file mode 100644 index 00000000..99ccc855 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/ColorPaletteList.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/ColorTokenList.vue b/src/prototypes/example-codex-kitchen-sink/playground/ColorTokenList.vue new file mode 100644 index 00000000..a5be698a --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/ColorTokenList.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/CursorTokenList.vue b/src/prototypes/example-codex-kitchen-sink/playground/CursorTokenList.vue new file mode 100644 index 00000000..4b2a6436 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/CursorTokenList.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/DimensionTokenList.vue b/src/prototypes/example-codex-kitchen-sink/playground/DimensionTokenList.vue new file mode 100644 index 00000000..6228b253 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/DimensionTokenList.vue @@ -0,0 +1,370 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/FontFamilyList.vue b/src/prototypes/example-codex-kitchen-sink/playground/FontFamilyList.vue new file mode 100644 index 00000000..a34ce5cf --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/FontFamilyList.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/FontSizeList.vue b/src/prototypes/example-codex-kitchen-sink/playground/FontSizeList.vue new file mode 100644 index 00000000..38a466a7 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/FontSizeList.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/FontWeightList.vue b/src/prototypes/example-codex-kitchen-sink/playground/FontWeightList.vue new file mode 100644 index 00000000..e51b4dc6 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/FontWeightList.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/LineHeightList.vue b/src/prototypes/example-codex-kitchen-sink/playground/LineHeightList.vue new file mode 100644 index 00000000..82fd3bef --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/LineHeightList.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/OpacityTokenList.vue b/src/prototypes/example-codex-kitchen-sink/playground/OpacityTokenList.vue new file mode 100644 index 00000000..60ca4b02 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/OpacityTokenList.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/OutlineTokenList.vue b/src/prototypes/example-codex-kitchen-sink/playground/OutlineTokenList.vue new file mode 100644 index 00000000..f75ccf59 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/OutlineTokenList.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundCell.vue b/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundCell.vue new file mode 100644 index 00000000..b8b3283c --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundCell.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundGrid.vue b/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundGrid.vue new file mode 100644 index 00000000..0ff6e425 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundGrid.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundList.vue b/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundList.vue new file mode 100644 index 00000000..a8eec57a --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundList.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundSection.vue b/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundSection.vue new file mode 100644 index 00000000..15b65588 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundSection.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundSubTabs.vue b/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundSubTabs.vue new file mode 100644 index 00000000..9db451f7 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundSubTabs.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundTab.vue b/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundTab.vue new file mode 100644 index 00000000..7c9f21da --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/PlaygroundTab.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/TextDecorationList.vue b/src/prototypes/example-codex-kitchen-sink/playground/TextDecorationList.vue new file mode 100644 index 00000000..fa2bbc46 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/TextDecorationList.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/TextOverflowList.vue b/src/prototypes/example-codex-kitchen-sink/playground/TextOverflowList.vue new file mode 100644 index 00000000..dff5b6a2 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/TextOverflowList.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/TokenDeprecatedLabel.vue b/src/prototypes/example-codex-kitchen-sink/playground/TokenDeprecatedLabel.vue new file mode 100644 index 00000000..8ecaf2e4 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/TokenDeprecatedLabel.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/TokenListGroupHeading.vue b/src/prototypes/example-codex-kitchen-sink/playground/TokenListGroupHeading.vue new file mode 100644 index 00000000..f9478f3b --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/TokenListGroupHeading.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/TokenSwatch.vue b/src/prototypes/example-codex-kitchen-sink/playground/TokenSwatch.vue new file mode 100644 index 00000000..dec6e475 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/TokenSwatch.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/playground/ZIndexTokenList.vue b/src/prototypes/example-codex-kitchen-sink/playground/ZIndexTokenList.vue new file mode 100644 index 00000000..bc97082b --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/playground/ZIndexTokenList.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/sections/ButtonsSection.vue b/src/prototypes/example-codex-kitchen-sink/sections/ButtonsSection.vue new file mode 100644 index 00000000..4c63ea41 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/sections/ButtonsSection.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/sections/ColorSection.vue b/src/prototypes/example-codex-kitchen-sink/sections/ColorSection.vue new file mode 100644 index 00000000..1a9dcca0 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/sections/ColorSection.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/prototypes/example-codex-kitchen-sink/sections/ContentDataSection.vue b/src/prototypes/example-codex-kitchen-sink/sections/ContentDataSection.vue new file mode 100644 index 00000000..1d2ea4ff --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/sections/ContentDataSection.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/sections/FeedbackSection.vue b/src/prototypes/example-codex-kitchen-sink/sections/FeedbackSection.vue new file mode 100644 index 00000000..ea2dade9 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/sections/FeedbackSection.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/sections/FormElementsSection.vue b/src/prototypes/example-codex-kitchen-sink/sections/FormElementsSection.vue new file mode 100644 index 00000000..634ec670 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/sections/FormElementsSection.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/sections/IconsSection.vue b/src/prototypes/example-codex-kitchen-sink/sections/IconsSection.vue new file mode 100644 index 00000000..289ceffd --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/sections/IconsSection.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/sections/MediaSection.vue b/src/prototypes/example-codex-kitchen-sink/sections/MediaSection.vue new file mode 100644 index 00000000..b3764b41 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/sections/MediaSection.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/sections/NavigationSection.vue b/src/prototypes/example-codex-kitchen-sink/sections/NavigationSection.vue new file mode 100644 index 00000000..8863eb16 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/sections/NavigationSection.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/sections/SearchSection.vue b/src/prototypes/example-codex-kitchen-sink/sections/SearchSection.vue new file mode 100644 index 00000000..b5d08cde --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/sections/SearchSection.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/src/prototypes/example-codex-kitchen-sink/sections/TokenSection.vue b/src/prototypes/example-codex-kitchen-sink/sections/TokenSection.vue new file mode 100644 index 00000000..7d6f63e0 --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/sections/TokenSection.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/prototypes/example-codex-kitchen-sink/sections/TypographySection.vue b/src/prototypes/example-codex-kitchen-sink/sections/TypographySection.vue new file mode 100644 index 00000000..87f18e4e --- /dev/null +++ b/src/prototypes/example-codex-kitchen-sink/sections/TypographySection.vue @@ -0,0 +1,67 @@ + + +