)`, and a * SEPARATE `{% if design. %}` guarded block that only ever emits a * `--bde-fc-token: ;` override line when the user actually picked a * value. An empty/untouched control therefore emits ZERO CSS beyond the * always-present base rule, per docs/tokens.md par. 4 "Lege overrides * emitteren geen CSS" and the task brief's binding rule against dead CSS. * No snippet below ever emits `!important`; overriding at the correct layer * (the element's own scoped custom property) is what makes that unnecessary. * * Documented exception (FIXTAAK v0.5.0+1, TAAK 1/3): not every property below * is a token override. `spacingSection()`/`spacingCssSnippet()` already * established, before this task, that margin/padding are plain conditional * CSS with NO `--bde-fc-*` token at all, because a shared token would force * one :root fallback onto every consumer even though each element's spacing * genuinely needs a different default. `typographySection()`'s new size/ * line-height/letter-spacing/align fields and `containerSection()` follow the * exact same reasoning for the exact same reason (evidenced concretely in * this task's own report: Commerce_View and Product_Card already hand-roll * genuinely different background/radius/shadow defaults today), so they use * the new `directCssSnippet()` helper instead of `overrideSnippet()`/ * `tokenVar()`. `formsSection()` and the size/border fields on * `buttonSection()`/`badgeSection()` remain real token overrides, because * those DO share one sane default across every consumer (see docs/tokens.md * par. 9 for the general rule this codifies). */ final class ControlPresets { /** * @var array|null Lazily built name => fallback map from * Tokens::definitions(), so this file never duplicates a fallback * literal that already lives in Tokens.php (architectuurprincipe 14, * "geen dubbele bron van waarheid"). */ private static ?array $tokenFallbacks = null; /** * @return array */ private static function tokenFallbackMap(): array { if (self::$tokenFallbacks === null) { self::$tokenFallbacks = []; foreach (Tokens::definitions() as $definition) { self::$tokenFallbacks[$definition['name']] = $definition['fallback']; } } return self::$tokenFallbacks; } /** * The literal fallback chain Tokens.php declares for a token name. Falls * back to an empty string (never a fatal, never a guessed value) if the * token name is not registered, so a typo shows up as visibly missing * CSS rather than a silently wrong value. */ public static function tokenFallback(string $tokenName): string { return self::tokenFallbackMap()[$tokenName] ?? ''; } /** * The `var(--bde-fc-x, )` expression a base CSS rule should * use, exactly matching docs/tokens.md par. 4's "zelfde letterlijke * fallback als in Tokens.php" rule. */ public static function tokenVar(string $tokenName): string { $fallback = self::tokenFallback($tokenName); return $fallback === '' ? "var({$tokenName})" : "var({$tokenName}, {$fallback})"; } /** * A single leaf control in the exact shape Breakdance's own `c()` helper * returns (`plugin/elements/elements-helpers.php:122-142`), built as a * plain array so this file never needs to call that function. * * @param array> $children * @param array $options * @param array $keywords * @return array */ private static function control( string $slug, string $label, array $children, array $options, bool $enableMediaQueries, bool $enableHover, array $keywords = [] ): array { return [ 'slug' => $slug, 'label' => $label, 'options' => $options, 'enableMediaQueries' => $enableMediaQueries, 'enableHover' => $enableHover, 'children' => $children, 'keywords' => $keywords, ]; } private static function colorControl(string $slug, string $label): array { return self::control($slug, $label, [], ['type' => 'color', 'layout' => 'inline'], false, false); } private static function unitControl(string $slug, string $label, bool $breakpointable = true): array { return self::control($slug, $label, [], ['type' => 'unit', 'layout' => 'inline'], $breakpointable, false); } /** * A single dropdown/select leaf control, same `type: dropdown` shape as * Breakdance's own native controls (verified against * WooPresetInputs/element.php) and as * `FluentCartBreakdance\Rendering\Atoms\AtomControls::selectControl()`'s * content-control counterpart, built via the local `control()` array * shape so this file keeps loading without Breakdance's own `c()`. * * @param array $choices */ private static function selectControl(string $slug, string $label, array $choices, string $default): array { return self::control($slug, $label, [], [ 'type' => 'dropdown', 'layout' => 'inline', 'items' => $choices, 'default' => $default, ], false, false); } private static function section(string $slug, string $label, array $children): array { return self::control( $slug, $label, $children, ['type' => 'section', 'sectionOptions' => ['type' => 'popout']], false, false ); } /** * A `{% if design.. %} ... {% endif %}` guarded override * block that sets exactly one `--bde-fc-*` custom property to the * control's own value. Emits nothing when the control is empty (docs/ * tokens.md par. 4). */ private static function overrideSnippet(string $selector, string $controlPath, string $tokenName): string { return "{% if {$controlPath} %}\n" . "{$selector} {\n" . " {$tokenName}: {{ {$controlPath} }};\n" . "}\n" . "{% endif %}\n"; } /** * Same override shape as overrideSnippet(), but for a unit-type control whose Twig * value needs the `.style` accessor to resolve to CSS-ready text (verified live * against Breakdance's own `Product_Card/css.twig`, `design.card.radius.style`, and * this file's own spacingCssSnippet()); overrideSnippet()'s bare `{{ path }}` * interpolation only suits a scalar color/dropdown control, never a unit one. Used * where the CSS custom property being overridden IS a real token (formsCssSnippet()'s * border width/radius); see directCssSnippet() for the equivalent shape when there is * deliberately no token at all. */ /** * CSS initialises `border-style` to `none`, and with no style the used border * width computes to 0. A snippet that only emits border-color and border-width * therefore renders no visible border at all, which made every border control in * this file a dead control. Emitting the style unconditionally in a base rule is * NOT the fix: the button base rule already declares a 1px transparent border * that currently computes to 0, so forcing a style there would widen every button * by 2px. This guard is therefore emitted only alongside a real user override, so * untouched elements keep their exact current geometry. * * @param array $controlPaths Any one of these being set turns the border on. */ private static function borderStyleGuard(string $selector, array $controlPaths): string { if (!$controlPaths) { return ''; } return '{% if ' . implode(' or ', $controlPaths) . " %}\n" . "{$selector} {\n" . " border-style: solid;\n" . "}\n" . "{% endif %}\n"; } private static function unitOverrideSnippet(string $selector, string $controlPath, string $tokenName): string { return "{% if {$controlPath} %}\n" . "{$selector} {\n" . " {$tokenName}: {{ {$controlPath}.style }};\n" . "}\n" . "{% endif %}\n"; } /** * A `{% if PATH %} SELECTOR { PROPERTY: VALUE; } {% endif %}` block that sets a plain * CSS property directly from a control's own value, deliberately with NO `--bde-fc-*` * custom property involved. Reserved for properties where a shared :root token would * be actively wrong: font size/line height/letter spacing/text align * (typographySection(), FIXTAAK TAAK 1) and the container frame (containerSection(), * TAAK 3) both need a genuinely different default per consuming element (a product * title's font size is not a product SKU's font size; Commerce_View's current "no * background" is not Product_Card's current "white card with a shadow"), so a single * registered token would silently unify several different existing defaults into one * the moment assets/fcbd-tokens.css loads, exactly the failure mode this task brief * warns against. spacingSection()/spacingCssSnippet() already established this same * "no token, direct conditional CSS" shape for margin/padding before this task; this * helper only generalizes that existing, proven pattern instead of hand-writing it * again per field. Same condition style as spacingCssSnippet() (bare control path, * `.style` only inside the value for a unit control), so generated output reads as * the same shape as the rest of this file. */ private static function directCssSnippet(string $selector, string $controlPath, string $cssProperty, bool $isUnit): string { $value = $isUnit ? "{{ {$controlPath}.style }}" : "{{ {$controlPath} }}"; return "{% if {$controlPath} %}\n" . "{$selector} {\n" . " {$cssProperty}: {$value};\n" . "}\n" . "{% endif %}\n"; } // ------------------------------------------------------------------ // Typography (text + headings families) // ------------------------------------------------------------------ /** * A responsive (enableMediaQueries) text-align dropdown, the same four values * Breakdance's own native typography preset offers (verified live against * `plugin/elements/preset-sections/typography.php`'s own `text_align` control: * left/center/right/justify, also `enableMediaQueries => true` there, so this mirrors * upstream's own responsiveness choice, not just this task brief's minimum). */ private static function textAlignControl(string $slug, string $label): array { return self::control($slug, $label, [], [ 'type' => 'dropdown', 'layout' => 'inline', 'items' => [ ['value' => 'left', 'text' => 'Left'], ['value' => 'center', 'text' => 'Center'], ['value' => 'right', 'text' => 'Right'], ['value' => 'justify', 'text' => 'Justify'], ], ], true, false); } /** * @param array $parts Which typography buckets to expose, in order. * Defaults to ['text', 'heading'] (identical to this method's own * pre-FIXTAAK-v0.5.0+1 behavior, so every existing zero/one/two-arg call site * keeps working unchanged, same badgeSection()-established contract as * $states there). Pass a narrower list (e.g. ['text']) for an element whose * markup never renders a `.fcbd-heading` node, so its control tree does not * show Heading fields that can never do anything: this task's own report * documents that, today, only FC Products/FC Related Products' card title * (`CardParts::renderCard()`) and FC Product Title's own hand-adapted `

` * rule actually consume the heading tokens; every other one of the ~35 other * call sites currently exposes a dead Heading Color/Heading Font Family pair. * This method only builds the capability; narrowing an actual element's own * call site is explicitly next-wave work (bd-elements/ is out of this fork's * file area). * @return array A `c()`-shaped "Typography" section: text color, * muted-text color, font size, line height, letter spacing and text align for * the 'text' bucket; heading color, heading font family, heading font size, * heading line height, heading letter spacing and heading text align for the * 'heading' bucket. The first two text fields and the first two heading fields * are byte-for-byte unchanged from before this task; the eight size/spacing/ * align fields are new (FIXTAAK TAAK 1) and, per docs/tokens.md par. 4, emit no * CSS at all until a page author actually sets one (proven in this task's * report). */ public static function typographySection(string $slug = 'typography', string $label = 'Typography', array $parts = ['text', 'heading']): array { $children = []; if (in_array('text', $parts, true)) { $children[] = self::colorControl('text_color', 'Text Color'); $children[] = self::colorControl('muted_text_color', 'Muted Text Color'); $children[] = self::unitControl('font_size', 'Font Size'); $children[] = self::unitControl('line_height', 'Line Height'); $children[] = self::unitControl('letter_spacing', 'Letter Spacing'); $children[] = self::textAlignControl('text_align', 'Text Align'); } if (in_array('heading', $parts, true)) { $children[] = self::colorControl('heading_color', 'Heading Color'); $children[] = self::control( 'heading_font_family', 'Heading Font Family', [], ['type' => 'font_family', 'layout' => 'inline'], false, false ); $children[] = self::unitControl('heading_font_size', 'Heading Font Size'); $children[] = self::unitControl('heading_line_height', 'Heading Line Height'); $children[] = self::unitControl('heading_letter_spacing', 'Heading Letter Spacing'); $children[] = self::textAlignControl('heading_text_align', 'Heading Text Align'); } return self::section($slug, $label, $children); } /** * @param string $selector The element's own %%SELECTOR%% + nesting (e.g. "%%SELECTOR%%" or "%%SELECTOR%% .fcbd-title"). * @param string $controlPathPrefix The design-control path prefix (e.g. "design.typography"). * @param array $parts Same filter as typographySection(); keep the two * in sync for a given element (same contract badgeSection()/badgeCssSnippet() * already established for their own shared $states array). */ public static function typographyCssSnippet(string $selector = '%%SELECTOR%%', string $controlPathPrefix = 'design.typography', array $parts = ['text', 'heading']): string { $includeText = in_array('text', $parts, true); $includeHeading = in_array('heading', $parts, true); $headingSelector = "{$selector} .fcbd-heading"; $snippet = "/* Typography (tokens-first, FluentCartBreakdance\\Design\\ControlPresets::typographySection) */\n"; if ($includeText) { $snippet .= "{$selector} {\n" . ' color: ' . self::tokenVar('--bde-fc-text-color') . ";\n" . "}\n" . "{$selector} .fcbd-muted {\n" . ' color: ' . self::tokenVar('--bde-fc-text-muted-color') . ";\n" . "}\n"; } if ($includeHeading) { $snippet .= "{$headingSelector} {\n" . ' color: ' . self::tokenVar('--bde-fc-heading-color') . ";\n" . ' font-family: ' . self::tokenVar('--bde-fc-heading-font-family') . ";\n" . "}\n"; } // Direct CSS, no token (see this file's own class docblock, "Documented // exception", and directCssSnippet()'s docblock): font size/line height/letter // spacing/text align are new in FIXTAAK TAAK 1 and are all enableMediaQueries so // a page author can size a heading/body text down on mobile, exactly the pain // point the task brief describes. if ($includeText) { $snippet .= self::directCssSnippet($selector, "{$controlPathPrefix}.font_size", 'font-size', true); $snippet .= self::directCssSnippet($selector, "{$controlPathPrefix}.line_height", 'line-height', true); $snippet .= self::directCssSnippet($selector, "{$controlPathPrefix}.letter_spacing", 'letter-spacing', true); $snippet .= self::directCssSnippet($selector, "{$controlPathPrefix}.text_align", 'text-align', false); } if ($includeHeading) { $snippet .= self::directCssSnippet($headingSelector, "{$controlPathPrefix}.heading_font_size", 'font-size', true); $snippet .= self::directCssSnippet($headingSelector, "{$controlPathPrefix}.heading_line_height", 'line-height', true); $snippet .= self::directCssSnippet($headingSelector, "{$controlPathPrefix}.heading_letter_spacing", 'letter-spacing', true); $snippet .= self::directCssSnippet($headingSelector, "{$controlPathPrefix}.heading_text_align", 'text-align', false); } if ($includeText) { $snippet .= self::overrideSnippet($selector, "{$controlPathPrefix}.text_color", '--bde-fc-text-color'); $snippet .= self::overrideSnippet($selector, "{$controlPathPrefix}.muted_text_color", '--bde-fc-text-muted-color'); } if ($includeHeading) { $snippet .= self::overrideSnippet($selector, "{$controlPathPrefix}.heading_color", '--bde-fc-heading-color'); $snippet .= self::overrideSnippet($selector, "{$controlPathPrefix}.heading_font_family", '--bde-fc-heading-font-family'); } return $snippet; } // ------------------------------------------------------------------ // Prices (regular/sale/compare, via --bde-fc-price-*). The most-viewed // text on a product page (task brief FIXTAAK, TAAK 1): Tokens.php has // defined the full `prices` token family since WP11A and three css.twig // files already READ it (FC_Product_Price, FC_Buy_Section, // FC_Product_Gallery), but until this preset no control anywhere ever // WROTE it, so it was only reachable via raw custom CSS. // ------------------------------------------------------------------ /** * @return array A `c()`-shaped "Prices" section: regular * price color, sale price color, compare (struck-through) price * color, and the compare price's text-decoration, matching the * `prices` token family exactly (Tokens.php, `--bde-fc-price-*`). */ public static function priceSection(string $slug = 'prices', string $label = 'Prices'): array { return self::section($slug, $label, [ self::colorControl('regular_color', 'Regular Price Color'), self::colorControl('sale_color', 'Sale Price Color'), self::colorControl('compare_color', 'Compare Price Color'), self::selectControl( 'compare_text_decoration', 'Compare Price Text Decoration', [ ['value' => 'line-through', 'text' => 'Line-through'], ['value' => 'underline', 'text' => 'Underline'], ['value' => 'overline', 'text' => 'Overline'], ['value' => 'none', 'text' => 'None'], ], 'line-through' ), ]); } /** * @param string $selector The element's own %%SELECTOR%% + nesting. * @param string $controlPathPrefix The design-control path prefix (e.g. "design.prices"). */ public static function priceCssSnippet(string $selector = '%%SELECTOR%%', string $controlPathPrefix = 'design.prices'): string { $snippet = "/* Prices (tokens-first, FluentCartBreakdance\\Design\\ControlPresets::priceSection) */\n" . "{$selector} .fct-item-price,\n" . "{$selector} .fct-min-price,\n" . "{$selector} .fct-max-price {\n" . ' color: ' . self::tokenVar('--bde-fc-price-regular-color') . ";\n" . "}\n" . "{$selector} .fcbd-price--on-sale .fct-item-price {\n" . ' color: ' . self::tokenVar('--bde-fc-price-sale-color') . ";\n" . "}\n" . "{$selector} .fct-compare-price del {\n" . ' color: ' . self::tokenVar('--bde-fc-price-compare-color') . ";\n" . ' text-decoration: ' . self::tokenVar('--bde-fc-price-compare-text-decoration') . ";\n" . "}\n"; $snippet .= self::overrideSnippet($selector, "{$controlPathPrefix}.regular_color", '--bde-fc-price-regular-color'); $snippet .= self::overrideSnippet($selector, "{$controlPathPrefix}.sale_color", '--bde-fc-price-sale-color'); $snippet .= self::overrideSnippet($selector, "{$controlPathPrefix}.compare_color", '--bde-fc-price-compare-color'); $snippet .= self::overrideSnippet($selector, "{$controlPathPrefix}.compare_text_decoration", '--bde-fc-price-compare-text-decoration'); return $snippet; } // ------------------------------------------------------------------ // Spacing (breakpointable, native Breakdance media queries only: // no custom breakpoint control of our own, per the binding rule // "breakpoints alleen native") // ------------------------------------------------------------------ /** * @return array A `c()`-shaped "Spacing" section, margin * top/bottom and internal padding, all breakpointable via * Breakdance's own native `enableMediaQueries` flag. */ public static function spacingSection(string $slug = 'spacing', string $label = 'Spacing'): array { return self::section($slug, $label, [ self::unitControl('margin_top', 'Margin Top'), self::unitControl('margin_bottom', 'Margin Bottom'), self::unitControl('padding', 'Padding'), ]); } public static function spacingCssSnippet(string $selector = '%%SELECTOR%%', string $controlPathPrefix = 'design.spacing'): string { return "/* Spacing (tokens-first, FluentCartBreakdance\\Design\\ControlPresets::spacingSection) */\n" . "{$selector} {\n" . "{% if {$controlPathPrefix}.margin_top %}\n" . " margin-top: {{ {$controlPathPrefix}.margin_top.style }};\n" . "{% endif %}\n" . "{% if {$controlPathPrefix}.margin_bottom %}\n" . " margin-bottom: {{ {$controlPathPrefix}.margin_bottom.style }};\n" . "{% endif %}\n" . "{% if {$controlPathPrefix}.padding %}\n" . " padding: {{ {$controlPathPrefix}.padding.style }};\n" . "{% endif %}\n" . "}\n"; } // ------------------------------------------------------------------ // Buttons (default/hover/focus-visible/disabled via --bde-fc-button-*) // ------------------------------------------------------------------ /** * @return array A `c()`-shaped "Button" section with four * state subsections (Default, Hover, Focus visible, Disabled) plus a * shared Style subsection (radius/border width/font weight/padding/ * line height), matching the `buttons` token family exactly * (docs/tokens.md par. 6, `--bde-fc-button-*`). */ public static function buttonSection(string $slug = 'button', string $label = 'Button'): array { return self::section($slug, $label, [ self::section('default_state', 'Default', [ self::colorControl('background', 'Background'), self::colorControl('text_color', 'Text Color'), self::colorControl('border_color', 'Border Color'), ]), self::section('hover_state', 'Hover', [ self::colorControl('background', 'Background'), self::colorControl('text_color', 'Text Color'), self::colorControl('border_color', 'Border Color'), ]), self::section('focus_visible_state', 'Focus visible', [ self::colorControl('ring_color', 'Focus Ring Color'), self::unitControl('ring_width', 'Focus Ring Width', false), ]), self::section('disabled_state', 'Disabled', [ self::colorControl('background', 'Background'), self::colorControl('text_color', 'Text Color'), self::control( 'opacity', 'Opacity', [], ['type' => 'number', 'layout' => 'inline', 'rangeOptions' => ['min' => 0, 'max' => 1, 'step' => 0.05]], false, false ), ]), self::section('style', 'Style', [ self::unitControl('border_radius', 'Border Radius', false), self::unitControl('border_width', 'Border Width', false), self::control( 'font_weight', 'Font Weight', [], ['type' => 'number', 'layout' => 'inline', 'rangeOptions' => ['min' => 100, 'max' => 900, 'step' => 100]], false, false ), self::unitControl('padding', 'Padding', false), self::unitControl('line_height', 'Line Height', false), ]), ]); } public static function buttonCssSnippet(string $selector = '%%SELECTOR%% button', string $controlPathPrefix = 'design.button'): string { $snippet = "/* Button (tokens-first, FluentCartBreakdance\\Design\\ControlPresets::buttonSection) */\n" . "{$selector} {\n" . ' background-color: ' . self::tokenVar('--bde-fc-button-bg') . ";\n" . ' color: ' . self::tokenVar('--bde-fc-button-text-color') . ";\n" . ' border-color: ' . self::tokenVar('--bde-fc-button-border-color') . ";\n" . ' border-radius: ' . self::tokenVar('--bde-fc-button-border-radius') . ";\n" . ' border-width: ' . self::tokenVar('--bde-fc-button-border-width') . ";\n" . ' font-weight: ' . self::tokenVar('--bde-fc-button-font-weight') . ";\n" . ' padding: ' . self::tokenVar('--bde-fc-button-padding') . ";\n" . ' line-height: ' . self::tokenVar('--bde-fc-button-line-height') . ";\n" . "}\n" // Per-part suffixing, not "{$selector}:hover": see suffixSelector()'s docblock. // This method's own $selector is a caller-supplied string, and two shipped call // sites (FC_Products and FC_Related_Products, both // '%%SELECTOR%% .fcbd-products__part--button button, %%SELECTOR%% .fcbd-products__part--button a') // pass a comma list, so concatenation bound every state suffix to the trailing // 'a' alone and left the bare 'button' part in each state rule as an // unconditional rule. Verified live on sparkcore-shop.local before this fix: an // untouched FC Products card button computed to the DISABLED palette // (background #e5e7eb, color #9ca3af, opacity 0.6) plus a permanent // 'outline: solid 2px' focus ring, because those rules sit later in the sheet at // the same specificity as the base rule they were meant to refine. . self::suffixSelector($selector, ':hover') . " {\n" . ' background-color: ' . self::tokenVar('--bde-fc-button-bg-hover') . ";\n" . ' color: ' . self::tokenVar('--bde-fc-button-text-color-hover') . ";\n" . ' border-color: ' . self::tokenVar('--bde-fc-button-border-color-hover') . ";\n" . "}\n" . self::suffixSelector($selector, ':focus-visible') . " {\n" . ' outline-color: ' . self::tokenVar('--bde-fc-button-focus-visible-ring-color') . ";\n" . ' outline-width: ' . self::tokenVar('--bde-fc-button-focus-visible-ring-width') . ";\n" . " outline-style: solid;\n" . "}\n" . self::suffixSelector($selector, ':disabled') . ",\n" . self::suffixSelector($selector, '[aria-disabled="true"]') . " {\n" . ' background-color: ' . self::tokenVar('--bde-fc-button-disabled-bg') . ";\n" . ' color: ' . self::tokenVar('--bde-fc-button-disabled-text-color') . ";\n" . ' opacity: ' . self::tokenVar('--bde-fc-button-disabled-opacity') . ";\n" . "}\n"; $map = [ "{$controlPathPrefix}.default_state.background" => '--bde-fc-button-bg', "{$controlPathPrefix}.default_state.text_color" => '--bde-fc-button-text-color', "{$controlPathPrefix}.default_state.border_color" => '--bde-fc-button-border-color', "{$controlPathPrefix}.hover_state.background" => '--bde-fc-button-bg-hover', "{$controlPathPrefix}.hover_state.text_color" => '--bde-fc-button-text-color-hover', "{$controlPathPrefix}.hover_state.border_color" => '--bde-fc-button-border-color-hover', "{$controlPathPrefix}.focus_visible_state.ring_color" => '--bde-fc-button-focus-visible-ring-color', "{$controlPathPrefix}.disabled_state.background" => '--bde-fc-button-disabled-bg', "{$controlPathPrefix}.disabled_state.text_color" => '--bde-fc-button-disabled-text-color', "{$controlPathPrefix}.disabled_state.opacity" => '--bde-fc-button-disabled-opacity', "{$controlPathPrefix}.style.font_weight" => '--bde-fc-button-font-weight', ]; // Unit controls store {number, unit, style}; only `.style` is CSS-ready text. // Routing one through the scalar overrideSnippet() does not merely emit nothing, it // emits actively harmful CSS: Twig stringifies the stored array to the literal text // "Array" (plus a PHP "Array to string conversion" warning), so the generated sheet // carried `--bde-fc-button-border-radius: Array`. That IS a valid custom-property // declaration, so the subsequent `border-radius: var(--bde-fc-button-border-radius, // )` resolved to `border-radius: Array`, which is invalid at // computed-value time and therefore falls all the way back to the CSS initial value // rather than to this plugin's own fallback. Verified live on sparkcore-shop.local // against the shipped 0.5.0 build: a button set to radius 20px / border 7px / // padding 18px / line height 2 / focus ring 9px computed to radius 0px, border // width `medium`, padding 0px, line height `normal` and outline width `medium`, // losing both the requested value AND the 3px/1px/14px 24px/1/2px defaults. // // focus_visible_state.ring_width belongs here for exactly the same reason: it is a // unitControl() like the four Style fields, only declared in a different subsection. // // Note the two different things called "style" in the Style-subsection paths below: // the first is the subsection's own slug (buttonSection()'s `section('style', ...)`) // and the trailing one unitOverrideSnippet() appends is the unit control's CSS-ready // property, hence the legitimate-looking `...style.border_radius.style`. $unitMap = [ "{$controlPathPrefix}.focus_visible_state.ring_width" => '--bde-fc-button-focus-visible-ring-width', "{$controlPathPrefix}.style.border_radius" => '--bde-fc-button-border-radius', "{$controlPathPrefix}.style.border_width" => '--bde-fc-button-border-width', "{$controlPathPrefix}.style.padding" => '--bde-fc-button-padding', "{$controlPathPrefix}.style.line_height" => '--bde-fc-button-line-height', ]; foreach ($map as $controlPath => $tokenName) { $snippet .= self::overrideSnippet($selector, $controlPath, $tokenName); } foreach ($unitMap as $controlPath => $tokenName) { $snippet .= self::unitOverrideSnippet($selector, $controlPath, $tokenName); } $snippet .= self::borderStyleGuard($selector, [ "{$controlPathPrefix}.default_state.border_color", "{$controlPathPrefix}.style.border_width", ]); return $snippet; } // ------------------------------------------------------------------ // Badges (sale + stock states) // ------------------------------------------------------------------ /** * The full badge substate vocabulary badgeSection()/badgeCssSnippet() * can emit, keyed by slug: label for the control tree, [bg token, text * token, CSS modifier class] for the snippet. Single source of truth for * both methods below, so the two can never drift out of sync with each * other the way a hand-duplicated list would risk. * * @return array */ private static function badgeStateCatalog(): array { return [ 'sale' => ['Sale Badge', '--bde-fc-badge-sale-bg', '--bde-fc-badge-sale-text-color', 'sale'], 'stock_in' => ['In Stock Badge', '--bde-fc-badge-stock-in-bg', '--bde-fc-badge-stock-in-text-color', 'stock-in'], 'stock_low' => ['Low Stock Badge', '--bde-fc-badge-stock-low-bg', '--bde-fc-badge-stock-low-text-color', 'stock-low'], 'stock_out' => ['Out of Stock Badge', '--bde-fc-badge-stock-out-bg', '--bde-fc-badge-stock-out-text-color', 'stock-out'], ]; } /** * @param array $states Which badge substates to expose, in * order. Defaults to all four (identical to this method's own * pre-parameterization behavior, so every existing call site keeps * working unchanged). Pass a narrower list when an element can only * ever render a subset (task brief FIXTAAK, TAAK 3: FC_Product_Stock * never renders a sale badge, so its two sale fields were dead * controls that confused the page builder). * @return array A `c()`-shaped "Badges" section: one * subsection per requested state, background + text color each, * matching the `badges` token family exactly (docs/tokens.md par. 6). */ public static function badgeSection( string $slug = 'badges', string $label = 'Badges', array $states = ['sale', 'stock_in', 'stock_low', 'stock_out'] ): array { $catalog = self::badgeStateCatalog(); $children = []; foreach ($states as $state) { if (!isset($catalog[$state])) { continue; } [$stateLabel] = $catalog[$state]; $children[] = self::section($state, $stateLabel, [ self::colorControl('background', 'Background'), self::colorControl('text_color', 'Text Color'), ]); } return self::section($slug, $label, $children); } /** * @param array $states Same substate filter as * badgeSection(); keep the two in sync for a given element so the * CSS a control writes always matches the states the control tree * actually shows (the exact chain TAAK 3's regression test pins). */ public static function badgeCssSnippet( string $selector = '%%SELECTOR%% .fcbd-badge', string $controlPathPrefix = 'design.badges', array $states = ['sale', 'stock_in', 'stock_low', 'stock_out'] ): string { $catalog = self::badgeStateCatalog(); $snippet = "/* Badges (tokens-first, FluentCartBreakdance\\Design\\ControlPresets::badgeSection) */\n"; foreach ($states as $key) { if (!isset($catalog[$key])) { continue; } [, $bgToken, $textToken, $modifierClass] = $catalog[$key]; $stateSelector = "{$selector}--{$modifierClass}"; $snippet .= "{$stateSelector} {\n" . ' background-color: ' . self::tokenVar($bgToken) . ";\n" . ' color: ' . self::tokenVar($textToken) . ";\n" . "}\n"; $snippet .= self::overrideSnippet($stateSelector, "{$controlPathPrefix}.{$key}.background", $bgToken); $snippet .= self::overrideSnippet($stateSelector, "{$controlPathPrefix}.{$key}.text_color", $textToken); } return $snippet; } // ------------------------------------------------------------------ // Variations (resting/selected state of the variation picker, via // --bde-fc-variation-*). Task brief FIXTAAK, TAAK 2: the picker's // active/selected state was fully hardcoded in bd-elements/ // FC_Product_Variations/default.css through three CSS variables that // were never registered in Tokens.php (so always fell back to their // literal, unreachable by any control). Same resting_state/selected_state // subsection shape as buttonSection()'s default_state/hover_state, so // this reads as the same kind of "state pair" control a page builder // already knows from the Button section. // ------------------------------------------------------------------ /** * @return array A `c()`-shaped "Variations" section: * resting and selected state (background/text/border color each) * plus a shared Style subsection (border radius/padding), matching * the `variations` token family exactly (Tokens.php, * `--bde-fc-variation-*`). */ public static function variationsSection(string $slug = 'variations', string $label = 'Variations'): array { return self::section($slug, $label, [ self::section('resting_state', 'Resting', [ self::colorControl('background', 'Background'), self::colorControl('text_color', 'Text Color'), self::colorControl('border_color', 'Border Color'), ]), self::section('selected_state', 'Selected', [ self::colorControl('background', 'Background'), self::colorControl('text_color', 'Text Color'), self::colorControl('border_color', 'Border Color'), ]), self::section('style', 'Style', [ self::unitControl('border_radius', 'Border Radius', false), self::unitControl('padding', 'Padding', false), ]), ]); } /** * Unlike the other *CssSnippet() methods, the picker's resting/selected * item selectors are native FluentCart markup (`.fct-product-tab-nav-item`, * `.fct-product-variant-item`, gated behind an element's own * `[data-fcbd-*-style]` re-skin attribute), not a single plugin-owned BEM * class every consumer shares the way `.fcbd-badge--x` is. So this method * cannot default a selector the way badgeCssSnippet() does: the caller * supplies the exact, already-adapted selector list for its own markup * (docblock par. "verbatim or lightly adapted" above). * * Border radius/padding are deliberately NOT part of the * --bde-fc-variation-* token family: this element's "toggle" skin (a * pill, radius 999px) and "buttons" skin (a chip, radius 8px) ship * genuinely different literal defaults today, and a single token * registered in Tokens.php would resolve to ONE :root value for both * (var()'s fallback argument is only ever used when the variable is * wholly unset, never to let two call sites disagree on a "default"), * which would silently change one skin's shape the moment * assets/fcbd-tokens.css loads. Emitting a direct, ungated property * override here (same style spacingCssSnippet() already uses for * margin/padding, which also never went through Tokens.php) sidesteps * that: with no override chosen every skin keeps its own current shape * untouched, and a chosen value applies uniformly to every selector the * caller listed. * * @param array $restingSelectors Concrete selectors for the * picker's resting/unselected items (background/text/border read * the resting tokens). A lower-specificity resting selector also * matches that same item once selected, so it does not need to be * repeated in $selectedSelectors for that reason alone. * @param array $selectedSelectors Concrete selectors for the * picker's selected/active items (background/text/border read the * selected tokens). Pass only the properties that already vary by * state at that exact selector today (docs/tokens.md par. 4's * "geen dode CSS" applies to shape too: do not add a background to * a selected selector that never had one, just to fill the tuple). * @param array $shapeSelectors Concrete selectors that * should receive the shared border-radius/padding override when the * user sets one. Pass [] to skip the shape override entirely (e.g. * when every one of an element's item selectors has a skin-specific * default that must not be unified). */ public static function variationsCssSnippet( array $restingSelectors = [], array $selectedSelectors = [], array $shapeSelectors = [], string $controlPathPrefix = 'design.variations' ): string { $snippet = "/* Variations (tokens-first, FluentCartBreakdance\\Design\\ControlPresets::variationsSection) */\n"; if ($restingSelectors !== []) { $snippet .= implode(",\n", $restingSelectors) . " {\n" . ' background-color: ' . self::tokenVar('--bde-fc-variation-bg') . ";\n" . ' color: ' . self::tokenVar('--bde-fc-variation-text-color') . ";\n" . ' border-color: ' . self::tokenVar('--bde-fc-variation-border-color') . ";\n" . "}\n"; } if ($selectedSelectors !== []) { $snippet .= implode(",\n", $selectedSelectors) . " {\n" . ' background-color: ' . self::tokenVar('--bde-fc-variation-selected-bg') . ";\n" . ' color: ' . self::tokenVar('--bde-fc-variation-selected-text-color') . ";\n" . ' border-color: ' . self::tokenVar('--bde-fc-variation-selected-border-color') . ";\n" . "}\n"; } $map = [ "{$controlPathPrefix}.resting_state.background" => '--bde-fc-variation-bg', "{$controlPathPrefix}.resting_state.text_color" => '--bde-fc-variation-text-color', "{$controlPathPrefix}.resting_state.border_color" => '--bde-fc-variation-border-color', "{$controlPathPrefix}.selected_state.background" => '--bde-fc-variation-selected-bg', "{$controlPathPrefix}.selected_state.text_color" => '--bde-fc-variation-selected-text-color', "{$controlPathPrefix}.selected_state.border_color" => '--bde-fc-variation-selected-border-color', ]; foreach ($map as $controlPath => $tokenName) { $snippet .= self::overrideSnippet('%%SELECTOR%%', $controlPath, $tokenName); } if ($shapeSelectors !== []) { $joinedShapeSelectors = implode(",\n", $shapeSelectors); $snippet .= "{% if {$controlPathPrefix}.style.border_radius %}\n" . "{$joinedShapeSelectors} {\n" . " border-radius: {{ {$controlPathPrefix}.style.border_radius.style }};\n" . "}\n" . "{% endif %}\n"; $snippet .= "{% if {$controlPathPrefix}.style.padding %}\n" . "{$joinedShapeSelectors} {\n" . " padding: {{ {$controlPathPrefix}.style.padding.style }};\n" . "}\n" . "{% endif %}\n"; } return $snippet; } // ------------------------------------------------------------------ // Notices (info/success/warning/error) // ------------------------------------------------------------------ /** * @return array A `c()`-shaped "Notices" section: four * notice states (info/success/warning/error), background + text + * border color each, matching the `notices` token family exactly * (docs/tokens.md par. 6). */ public static function noticeSection(string $slug = 'notices', string $label = 'Notices'): array { $states = [ 'info' => 'Info', 'success' => 'Success', 'warning' => 'Warning', 'error' => 'Error', ]; $children = []; foreach ($states as $key => $stateLabel) { $children[] = self::section($key, $stateLabel, [ self::colorControl('background', 'Background'), self::colorControl('text_color', 'Text Color'), self::colorControl('border_color', 'Border Color'), ]); } return self::section($slug, $label, $children); } public static function noticeCssSnippet(string $selector = '%%SELECTOR%% .fcbd-notice', string $controlPathPrefix = 'design.notices'): string { $states = [ 'info' => ['--bde-fc-notice-info-bg', '--bde-fc-notice-info-text-color', '--bde-fc-notice-info-border-color'], 'success' => ['--bde-fc-notice-success-bg', '--bde-fc-notice-success-text-color', '--bde-fc-notice-success-border-color'], 'warning' => ['--bde-fc-notice-warning-bg', '--bde-fc-notice-warning-text-color', '--bde-fc-notice-warning-border-color'], 'error' => ['--bde-fc-notice-error-bg', '--bde-fc-notice-error-text-color', '--bde-fc-notice-error-border-color'], ]; $snippet = "/* Notices (tokens-first, FluentCartBreakdance\\Design\\ControlPresets::noticeSection) */\n"; foreach ($states as $key => [$bgToken, $textToken, $borderToken]) { $stateSelector = "{$selector}--{$key}"; $snippet .= "{$stateSelector} {\n" . ' background-color: ' . self::tokenVar($bgToken) . ";\n" . ' color: ' . self::tokenVar($textToken) . ";\n" . ' border-color: ' . self::tokenVar($borderToken) . ";\n" . "}\n"; $snippet .= self::overrideSnippet($stateSelector, "{$controlPathPrefix}.{$key}.background", $bgToken); $snippet .= self::overrideSnippet($stateSelector, "{$controlPathPrefix}.{$key}.text_color", $textToken); $snippet .= self::overrideSnippet($stateSelector, "{$controlPathPrefix}.{$key}.border_color", $borderToken); } return $snippet; } // ------------------------------------------------------------------ // Form field (input styling shared by every form-ish element in this // plugin). Reuses the existing --bde-fc-input-* tokens (Tokens.php // "forms" family). // // This block is now the SINGLE implementation of the default-state and // focus-state input portion. The two labeled-field supersets that each // used to carry their own byte-for-byte copy of it, // bd-elements/FC_Login/FormControlPresets::formFieldSection() and // src/Rendering/Checkout/CheckoutControlPresets::formFieldSection(), now // both call formsSectionChildren()/formsCssSnippet() for that shared // portion and layer only their own