semantic role -> YOOtheme/UIkit Less variables. * * This table is the product. Everything else is plumbing: it is what keeps an * imported design editable inside YOOtheme's style panel instead of a CSS dump. */ namespace Fyoo\FigmaBridge; class Mapper { /** role => [less variables it drives] */ public const ROLES = [ 'color.primary' => ['@global-primary-background'], 'color.secondary' => ['@global-secondary-background'], 'color.background' => ['@global-background'], 'color.muted' => ['@global-muted-background'], 'color.text' => ['@global-color'], 'color.emphasis' => ['@global-emphasis-color'], 'color.text-muted' => ['@global-muted-color'], 'color.inverse' => ['@global-inverse-color'], 'color.link' => ['@global-link-color'], 'color.danger' => ['@global-danger-background'], 'color.success' => ['@global-success-background'], 'color.warning' => ['@global-warning-background'], 'color.border' => ['@global-border'], 'type.body' => ['@global-font-family', '@global-font-size', '@global-line-height'], 'type.small' => ['@global-small-font-size'], 'type.lead' => ['@global-medium-font-size'], 'type.h1' => ['@base-h1-font-size'], 'type.h2' => ['@base-h2-font-size'], 'type.h3' => ['@base-h3-font-size'], 'type.h4' => ['@base-h4-font-size'], 'type.h5' => ['@base-h5-font-size'], 'type.h6' => ['@base-h6-font-size'], 'space.xsmall' => ['@global-small-margin'], 'space.small' => ['@global-margin'], 'space.medium' => ['@global-medium-margin'], 'space.large' => ['@global-large-margin'], 'space.xlarge' => ['@global-xlarge-margin'], 'space.gutter' => ['@global-gutter'], 'space.section' => ['@section-padding-vertical'], 'space.container' => ['@container-max-width'], 'radius.default' => ['@border-rounded-border-radius'], 'shadow.small' => ['@global-small-box-shadow'], 'shadow.medium' => ['@global-medium-box-shadow'], 'shadow.large' => ['@global-large-box-shadow'], 'shadow.xlarge' => ['@global-xlarge-box-shadow'], ]; /** * Ordered: first pattern that matches a token key wins. Order matters — * "text-muted" must be tested before "text", "muted" before "background". */ public const RULES = [ 'color' => [ '~(^|-)(on-primary|inverse|contrast)(-|$)~' => 'color.inverse', '~(^|-)(text|foreground|fg|ink|content)-(muted|secondary|subtle|tertiary)(-|$)~' => 'color.text-muted', '~(^|-)(muted|subtle)-(text|foreground|fg)(-|$)~' => 'color.text-muted', '~(^|-)(heading|title|emphasis|display)(-|$)~' => 'color.emphasis', '~(^|-)(text|foreground|fg|ink|body)(-|$)~' => 'color.text', '~(^|-)link(-|$)~' => 'color.link', '~(^|-)(danger|error|destructive|critical|negative)(-|$)~' => 'color.danger', '~(^|-)(success|positive|ok)(-|$)~' => 'color.success', '~(^|-)(warning|caution|alert)(-|$)~' => 'color.warning', '~(^|-)(border|divider|outline|stroke|separator)(-|$)~' => 'color.border', '~(^|-)(secondary|brand-2|accent-2)(-|$)~' => 'color.secondary', '~(^|-)(brand-)?(primary|accent|brand)(-|$)~' => 'color.primary', '~(^|-)(muted|subtle|surface-2|neutral-alt)(-|$)~' => 'color.muted', '~(^|-)(surface|background|bg|canvas|base|paper)(-|$)~' => 'color.background', ], 'typography' => [ '~(^|-)(h1|heading-(xl|1|hero|display))(-|$)~' => 'type.h1', '~(^|-)(h2|heading-(lg|2|large))(-|$)~' => 'type.h2', '~(^|-)(h3|heading-(md|3|medium))(-|$)~' => 'type.h3', '~(^|-)(h4|heading-(sm|4|small))(-|$)~' => 'type.h4', '~(^|-)(h5|heading-(xs|5))(-|$)~' => 'type.h5', '~(^|-)(h6|heading-(2xs|6)|overline|eyebrow)(-|$)~' => 'type.h6', '~(^|-)(lead|intro|subtitle|body-large)(-|$)~' => 'type.lead', '~(^|-)(small|caption|meta|footnote)(-|$)~' => 'type.small', '~(^|-)(body|base|paragraph|text|default)(-|$)~' => 'type.body', ], 'spacing' => [ '~(^|-)(container|max-width|content|content-width|wrapper|page-width)(-|$)~' => 'space.container', '~(^|-)(section|block|stack-xl)(-|$)~' => 'space.section', '~(^|-)(gutter|grid-gap|column-gap|gap)(-|$)~' => 'space.gutter', '~(^|-)(3xl|2xl|xxl|xlarge|xl)(-|$)~' => 'space.xlarge', '~(^|-)(large|lg)(-|$)~' => 'space.large', '~(^|-)(md|medium)(-|$)~' => 'space.medium', '~(^|-)(xs|xxs|2xs|xsmall)(-|$)~' => 'space.xsmall', '~(^|-)(sm|small|base|default)(-|$)~' => 'space.small', ], 'radius' => [ '~(^|-)(default|base|md|medium|card|button|control)(-|$)~' => 'radius.default', ], 'shadow' => [ '~(^|-)(3xl|2xl|xxl|xlarge|xl)(-|$)~' => 'shadow.xlarge', '~(^|-)(lg|large)(-|$)~' => 'shadow.large', '~(^|-)(md|medium|default|base|card)(-|$)~' => 'shadow.medium', '~(^|-)(sm|xs|small)(-|$)~' => 'shadow.small', ], ]; /** Figma component name -> semantic role -> YOOtheme element target. */ public const COMPONENTS = [ '~^button\s*/\s*primary~i' => ['action.primary', 'button-primary'], '~^button\s*/\s*secondary~i' => ['action.secondary', 'button-secondary'], '~^button~i' => ['action.default', 'button-default'], '~^card~i' => ['content.card', 'card-default'], '~^(nav|navbar|header)~i' => ['navigation.primary', 'navbar'], '~^(input|field|form)~i' => ['form.control', 'form'], '~^(alert|notice|banner)~i' => ['feedback.alert', 'alert'], '~^(section|hero)~i' => ['layout.section', 'section'], ]; private array $overrides; /** @param array $overrides token key => role, saved from the admin mapping table */ public function __construct(array $overrides = []) { $this->overrides = $overrides; } public function role(string $group, string $key): ?string { if (isset($this->overrides[$key])) { return $this->overrides[$key] === '-' ? null : $this->overrides[$key]; } foreach (self::RULES[$group] ?? [] as $pattern => $role) { if (preg_match($pattern, "-$key-")) { return $role; } } return null; } public function variables(string $role): array { return self::ROLES[$role] ?? []; } public function component(string $name): array { foreach (self::COMPONENTS as $pattern => $mapping) { if (preg_match($pattern, $name)) { return ['role' => $mapping[0], 'target' => $mapping[1]]; } } return ['role' => '', 'target' => '']; } /** * Full resolution table for one token graph: what maps where, and what did not * map at all (the admin UI shows these so a human can assign them). */ public function resolve(Tokens $tokens): array { $mapped = []; $unmapped = []; $claimed = []; foreach (Tokens::GROUPS as $group) { foreach ($tokens->group($group) as $key => $value) { $role = $this->role($group === 'typography' ? 'typography' : $group, $key); // First token to claim a role wins; later ones stay available as raw vars. if ($role === null || isset($claimed[$role])) { $unmapped[] = ['group' => $group, 'key' => $key, 'value' => $value, 'reason' => $role ? "role $role already taken" : 'no rule matched']; continue; } $claimed[$role] = $key; $mapped[] = [ 'group' => $group, 'key' => $key, 'value' => $value, 'role' => $role, 'variables' => $this->variables($role), ]; } } return ['mapped' => $mapped, 'unmapped' => $unmapped]; } }