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;
}