attribute_name ); add_action( "{$taxonomy}_add_form_fields", 'addon_boss_swatch_add_field' ); add_action( "{$taxonomy}_edit_form_fields", 'addon_boss_swatch_edit_field', 10, 2 ); add_action( "created_{$taxonomy}", 'addon_boss_save_swatch_field' ); add_action( "edited_{$taxonomy}", 'addon_boss_save_swatch_field' ); add_filter( "manage_edit-{$taxonomy}_columns", 'addon_boss_swatch_column' ); add_filter( "manage_{$taxonomy}_custom_column", 'addon_boss_swatch_column_content', 10, 3 ); } } // ── Add-term screen field ───────────────────────────────────────── function addon_boss_swatch_add_field() { ?>

term_id, 'bundle_boss_swatch_colour', true ); ?>

bundle_boss_swatch_colour ). Runs // once per site across the attribute taxonomies that can carry swatches. // ----------------------------------------------------------------- add_action( 'admin_init', 'addon_boss_migrate_swatch_meta_key' ); function addon_boss_migrate_swatch_meta_key() { if ( get_option( 'bundle_boss_swatch_meta_migrated' ) || ! function_exists( 'wc_get_attribute_taxonomies' ) ) { return; } foreach ( wc_get_attribute_taxonomies() as $attr ) { $taxonomy = wc_attribute_taxonomy_name( $attr->attribute_name ); $term_ids = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, 'fields' => 'ids', ) ); if ( is_wp_error( $term_ids ) ) { continue; } foreach ( $term_ids as $term_id ) { $old = get_term_meta( $term_id, 'bb_swatch_colour', true ); if ( '' !== $old && '' === get_term_meta( $term_id, 'bundle_boss_swatch_colour', true ) ) { update_term_meta( $term_id, 'bundle_boss_swatch_colour', $old ); delete_term_meta( $term_id, 'bb_swatch_colour' ); } } } update_option( 'bundle_boss_swatch_meta_migrated', 1, false ); } // ── Terms list column ───────────────────────────────────────────── function addon_boss_swatch_column( $columns ) { // Insert the swatch column right before the name column. $new = array(); foreach ( $columns as $key => $label ) { if ( 'name' === $key ) { $new['bb_swatch'] = __( 'Swatch', 'addon-boss' ); } $new[ $key ] = $label; } return $new; } function addon_boss_swatch_column_content( $content, $column_name, $term_id ) { if ( 'bb_swatch' !== $column_name ) { return $content; } $colour = get_term_meta( $term_id, 'bundle_boss_swatch_colour', true ); if ( ! $colour ) { return '—'; } return ''; } // ── Colour picker assets on term screens ────────────────────────── add_action( 'admin_enqueue_scripts', 'addon_boss_swatch_admin_assets' ); function addon_boss_swatch_admin_assets( $hook ) { if ( ! in_array( $hook, array( 'edit-tags.php', 'term.php' ), true ) ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $taxonomy = $screen ? (string) $screen->taxonomy : ''; if ( strpos( $taxonomy, 'pa_' ) !== 0 ) { return; } wp_enqueue_style( 'wp-color-picker' ); wp_enqueue_script( 'wp-color-picker' ); wp_add_inline_script( 'wp-color-picker', ' jQuery( function ( $ ) { function initSwatchPickers() { $( ".bb-swatch-colour-input" ).not( ".wp-color-picker" ).wpColorPicker(); } initSwatchPickers(); // The add-term form is reset via AJAX after a term is created; // re-init in case WP re-renders the field. $( document ).ajaxComplete( function () { initSwatchPickers(); } ); } ); ' ); } /** * The colour vocabulary: name => hex, the common CSS and apparel colour * names. ONE table serves both directions, so the two can never disagree: * a NAME fills in its swatch (addon_boss_swatch_from_name below) and a HEX * reads back as its nearest name (addon_boss_colour_label below). Primary * names stand before their synonyms on purpose; the reverse reading keeps * the first, so black is "Black", never "Jet". * * @return array */ function addon_boss_colour_map(): array { static $map = array( 'black' => '#1f2430', 'jet' => '#1f2430', 'onyx' => '#1f2430', 'white' => '#f7f7f4', 'ivory' => '#fffff0', 'cream' => '#f3ead3', 'eggshell' => '#f0ead6', 'snow' => '#f7f7f4', 'grey' => '#9aa0a6', 'gray' => '#9aa0a6', 'charcoal' => '#36454f', 'slate' => '#475569', 'silver' => '#c7ccd1', 'ash' => '#b2beb5', 'stone' => '#a8a29e', 'graphite' => '#3b3b3b', 'gunmetal' => '#2a3439', 'navy' => '#1e2a52', 'blue' => '#2563eb', 'royal blue' => '#1d4ed8', 'royal' => '#1d4ed8', 'cobalt' => '#1e40af', 'denim' => '#3b5b92', 'sky blue' => '#7dd3fc', 'sky' => '#7dd3fc', 'powder blue' => '#b0e0e6', 'baby blue' => '#bfdbfe', 'steel blue' => '#4682b4', 'azure' => '#1d7fff', 'sapphire' => '#0f52ba', 'teal' => '#0d9488', 'turquoise' => '#2bbbad', 'aqua' => '#22d3ee', 'aquamarine' => '#7fffd4', 'cyan' => '#06b6d4', 'red' => '#dc2626', 'crimson' => '#dc143c', 'scarlet' => '#ff2400', 'cherry' => '#b91c2c', 'ruby' => '#9b111e', 'maroon' => '#7f1d1d', 'burgundy' => '#6d213c', 'wine' => '#722f37', 'brick' => '#8b3a3a', 'rust' => '#b7410e', 'terracotta' => '#c66b3d', 'pink' => '#ec4899', 'rose' => '#e11d48', 'blush' => '#f3c0c0', 'fuchsia' => '#d946ef', 'salmon' => '#fa8072', 'coral' => '#ff7f50', 'peach' => '#ffcba4', 'magenta' => '#c026d3', 'orange' => '#ea7317', 'tangerine' => '#f28500', 'amber' => '#f59e0b', 'apricot' => '#fbceb1', 'copper' => '#b87333', 'bronze' => '#cd7f32', 'yellow' => '#f4c20d', 'gold' => '#d4af37', 'mustard' => '#d9a404', 'lemon' => '#f4e04d', 'canary' => '#f7e017', 'green' => '#16a34a', 'forest green' => '#1f7a3d', 'forest' => '#1f7a3d', 'emerald' => '#047857', 'kelly green' => '#4cbb17', 'lime' => '#84cc16', 'olive' => '#808000', 'sage' => '#9caf88', 'mint' => '#9be7c4', 'hunter green' => '#355e3b', 'sea green' => '#2e8b57', 'moss' => '#8a9a5b', 'seafoam' => '#9fe2bf', 'purple' => '#7c3aed', 'violet' => '#8b5cf6', 'lavender' => '#b794f4', 'lilac' => '#c8a2c8', 'indigo' => '#4f46e5', 'plum' => '#8e4585', 'orchid' => '#da70d6', 'mauve' => '#b784a7', 'eggplant' => '#614051', 'grape' => '#6f2da8', 'brown' => '#7c4a23', 'chocolate' => '#5b3a29', 'coffee' => '#6f4e37', 'mocha' => '#6f4e37', 'espresso' => '#3c2218', 'tan' => '#d2b48c', 'beige' => '#e3d5b8', 'khaki' => '#c3b091', 'camel' => '#c19a6b', 'taupe' => '#b39a82', 'sand' => '#c2b280', 'caramel' => '#af6e4d', 'walnut' => '#5c4033', 'chestnut' => '#954535', 'nude' => '#e3bc9a', // Apparel compound names, explicit so they beat the base-word fallback // (e.g. "Black Ash" must read as near-black, not the light "ash"). 'otter' => '#6b5a45', 'black ash' => '#26292e', 'oxford blue' => '#14213d', 'sapphire blue' => '#0f52ba', 'turquoise blue' => '#1ec8be', 'bottle green' => '#06402b', 'seal grey' => '#5c5b57', 'seal gray' => '#5c5b57', 'light grey' => '#d3d3d7', 'light gray' => '#d3d3d7', 'heather grey' => '#b6b6bb', 'heather gray' => '#b6b6bb', 'solid grey' => '#8b8f94', 'solid gray' => '#8b8f94', 'key lime' => '#bfef5a', 'sun orange' => '#ff7a1a', 'kelly green' => '#4cbb17', ); return $map; } /** * Best-guess swatch colour (hex) from a colour NAME, so swatches fill in by * themselves without the merchant setting one per option. Covers the common CSS * and apparel colour names; a compound name ("Royal Blue", "Heather Grey") falls * back to its base colour word. Returns '' when nothing matches, so callers * degrade gracefully to the colour's label rather than an empty dot. * * @param string $name Colour option label, e.g. "Royal Blue". * @return string Hex like "#1d4ed8", or '' if not recognised. */ function addon_boss_swatch_from_name( string $name ): string { $map = addon_boss_colour_map(); $key = strtolower( trim( $name ) ); if ( '' === $key ) { return ''; } if ( isset( $map[ $key ] ) ) { return $map[ $key ]; } // Compound names: try each word (last first, so "Royal Blue" resolves to blue // when the exact phrase is absent, "Heather Grey" to grey, "Light Olive" to olive). $words = preg_split( '/[\s\-_\/]+/', $key ); foreach ( array_reverse( (array) $words ) as $word ) { if ( '' !== $word && isset( $map[ $word ] ) ) { return $map[ $word ]; } } return ''; } /** * Whether the NAME-GUESS auto-swatch may paint a choice row at all: only when * every option would end up coloured, by its own explicit swatch or by a name * the vocabulary resolves. One recognised name among strangers (Lavender in a * scent set) used to paint a single purple tile in a row of text pills, mixed * shapes in one control; a row that cannot be uniform in dots stays uniform * in pills instead. Explicit per-option swatches are the merchant's own call * and are honoured regardless; only the guess is gated. Both seams ask here: * the sanitiser before it stores a guess, the storefront render before it * draws one. * * @param array $options The set's option rows (label, swatch). * @return bool True when the whole row resolves to colours. */ function addon_boss_swatch_row_uniform( array $options ): bool { foreach ( $options as $opt ) { if ( ! is_array( $opt ) ) { continue; } if ( '' !== (string) ( $opt['swatch'] ?? '' ) ) { continue; } // A colour the merchant cleared is a choice with no colour, exactly // like a name the vocabulary does not know. if ( ! empty( $opt['no_swatch'] ) ) { return false; } if ( '' === addon_boss_swatch_from_name( (string) ( $opt['label'] ?? '' ) ) ) { return false; } } return true; } /** * The choices whose colour could not be found, in a set they cost its dots. * * One name the vocabulary does not know ("Heather Mist") and nobody gave a * colour turns the whole row into plain buttons (addon_boss_swatch_row_uniform), * so Navy and Red lose their dots too, and nothing told the merchant why. This * names the choices to fix, and only where fixing them brings the dots back: * * - some choice has a colour, its own or a name we know (a set of sizes or * scents has none, and its buttons are right), * - no choice was cleared on purpose (a merchant who cleared one chose * buttons), * - the set is not drawn as photo cards, which keep each colour anyway. * * Every screen that flags it asks here: the set editor's first paint and the * list's card, chip and filter. The editor's script repeats the same rule as * the merchant types. * * @param array $set A stored option set. * @return array The labels to give a colour, in the set's order. */ function addon_boss_swatch_unmatched( array $set ): array { $options = array_filter( (array) ( $set['options'] ?? array() ), 'is_array' ); if ( ! $options ) { return array(); } if ( function_exists( 'addon_boss_option_set_shape' ) && 'card' === addon_boss_option_set_shape( $set ) ) { return array(); } $own = false; $guessed = 0; $missing = array(); foreach ( $options as $opt ) { if ( ! empty( $opt['no_swatch'] ) ) { return array(); } $label = trim( (string) ( $opt['label'] ?? '' ) ); if ( '' !== (string) ( $opt['swatch'] ?? '' ) ) { $own = true; } elseif ( '' !== addon_boss_swatch_from_name( $label ) ) { ++$guessed; } elseif ( '' !== $label ) { $missing[] = $label; } } return $missing && addon_boss_swatch_set_is_colours( (string) ( $set['name'] ?? '' ), $own, $guessed, count( $options ) ) ? $missing : array(); } /** * Whether a set is meant as colours, so a choice without one is a gap. * * A colour word or two is not enough: a Frame set reads "Print only, Oak, * Black, White" and a Mount set "No mount, White mount", and neither wants * dots (found on a real library, 12 Oct). So any one of these: * * - the merchant gave a choice a colour of their own (they asked for dots, * and the one gap is hiding them), * - the set's name says colour, * - more than half its choices are colour names we know. * * The set editor's script asks the same three questions as it is typed in. * * @param string $name The set's name. * @param bool $own Whether any choice carries its own colour. * @param int $guessed How many choices are colour names we know. * @param int $total How many choices the set has. */ function addon_boss_swatch_set_is_colours( string $name, bool $own, int $guessed, int $total ): bool { if ( $own ) { return true; } // Not one colour in it: whatever the name says, nothing is lost. if ( ! $guessed ) { return false; } if ( preg_match( '/\b(colou?rs?|shades?)\b/i', $name ) ) { return true; } return $guessed * 2 > $total; } /** * A colour's human name from its hex: "Gold" for #d4af37, the NEAREST * vocabulary name for a merchant's own shade. Wherever this prints, the hex * stands in brackets beside the name, so a near name can inform but never * lie; the hex stays the authority a maker matches ink against. Returns '' * for anything that is not a hex colour, so callers degrade to the bare * value exactly as before. * * Nearness is the redmean-weighted RGB distance: cheap, and close enough to * perception that a dark slate lands on a dark name rather than a light one. * Ties keep the FIRST table entry, which is the primary name by the table's * own ordering. * * @param string $hex "#d4af37", "#FFF" and friends. * @return string "Gold", "Royal Blue"... or '' when $hex is not a colour. */ function addon_boss_colour_label( string $hex ): string { static $cache = array(); $hex = strtolower( trim( $hex ) ); if ( preg_match( '/^#([0-9a-f]{3})$/', $hex, $m ) ) { $hex = '#' . $m[1][0] . $m[1][0] . $m[1][1] . $m[1][1] . $m[1][2] . $m[1][2]; } if ( ! preg_match( '/^#[0-9a-f]{6}$/', $hex ) ) { return ''; } if ( isset( $cache[ $hex ] ) ) { return $cache[ $hex ]; } $r = hexdec( substr( $hex, 1, 2 ) ); $g = hexdec( substr( $hex, 3, 2 ) ); $b = hexdec( substr( $hex, 5, 2 ) ); // The vocabulary, plus two reverse-only anchors. The table's own black // and white are softened UI shades (a blue-black, an off-white), which // is right for FILLING a swatch but wrong in reverse: pure #000000 sat // fractionally nearer Espresso than the blue-black. The anchors make // the pure poles exact hits without touching what any NAME fills in. $candidates = array(); foreach ( addon_boss_colour_map() as $bb_n => $bb_c ) { $candidates[] = array( $bb_n, $bb_c ); } $candidates[] = array( 'black', '#000000' ); $candidates[] = array( 'white', '#ffffff' ); $best = ''; $dist = PHP_FLOAT_MAX; foreach ( $candidates as $bb_cand ) { $c = $bb_cand[1]; $dr = $r - hexdec( substr( $c, 1, 2 ) ); $dg = $g - hexdec( substr( $c, 3, 2 ) ); $db = $b - hexdec( substr( $c, 5, 2 ) ); $rm = $r - $dr / 2; // the mean red of the pair. $d = ( 2 + $rm / 256 ) * $dr * $dr + 4 * $dg * $dg + ( 2 + ( 255 - $rm ) / 256 ) * $db * $db; if ( $d < $dist ) { $dist = $d; $best = $bb_cand[0]; } } $cache[ $hex ] = '' === $best ? '' : ucwords( $best ); return $cache[ $hex ]; } /** * The CSS `background` value for an option's swatch. A single colour returns its * hex; a two-tone option (swatch + swatch2, e.g. a "Navy/Red" jacket) returns a * clean diagonal split of the two. Returns '' when the option has no swatch, so * callers keep degrading to the option label. One helper so every render seam * (product page, configurator, grid tile, admin preview) shows two-tone alike. * * @param array $opt A sanitised option row (swatch, swatch2). * @return string A CSS background value, or '' when there is no colour. */ function addon_boss_swatch_css( array $opt ): string { $c1 = (string) ( $opt['swatch'] ?? '' ); $c2 = (string) ( $opt['swatch2'] ?? '' ); if ( '' === $c1 ) { return ''; } if ( '' !== $c2 ) { // A hairline white seam down the diagonal keeps the two halves crisp at any // size, even a 13px admin dot, and stops a white or light half blurring into // the border. The px seam holds a steady weight whatever the swatch size. return 'linear-gradient(135deg, ' . $c1 . ' 0 calc(50% - 0.6px), #fff calc(50% - 0.6px) calc(50% + 0.6px), ' . $c2 . ' calc(50% + 0.6px) 100%)'; } return $c1; }