wp-ux-design — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited wp-ux-design (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Definitive standards for building WordPress sites that are fast, accessible, and visually consistent. Every rule below is enforceable in code review.
The hero image or heading is almost always the LCP element. Prioritize it explicitly.
<!-- Preload the hero image in <head> -->
<link rel="preload" as="image" href="/wp-content/uploads/hero.webp"
fetchpriority="high" type="image/webp">
<!-- Mark the hero img element -->
<img src="hero.webp" alt="Hero banner" fetchpriority="high"
width="1280" height="720" decoding="async">WordPress-specific: disable lazy-load on the first image via filter.
// functions.php — skip lazy-load on above-fold images
add_filter( 'wp_img_tag_add_loading_attr', function( $value, $image, $context ) {
if ( str_contains( $image, 'hero-banner' ) ) {
return false; // no loading="lazy"
}
return $value;
}, 10, 3 );Every replaced element MUST have explicit dimensions.
/* Reserve space for images before load */
img, video, iframe {
max-width: 100%;
height: auto;
aspect-ratio: attr(width) / attr(height);
}
/* Prevent font-swap layout shift */
@font-face {
font-family: 'Brand';
src: url('brand.woff2') format('woff2');
font-display: swap;
size-adjust: 105%; /* match fallback metrics */
ascent-override: 95%;
}
/* Reserve ad/embed space */
.ad-slot { min-height: 250px; }
.embed-container { aspect-ratio: 16 / 9; }// Debounce expensive scroll/resize handlers
function debounce(fn, ms = 150) {
let id;
return (...args) => { clearTimeout(id); id = setTimeout(() => fn(...args), ms); };
}
window.addEventListener('scroll', debounce(handleScroll), { passive: true });
// Break long tasks with yield
async function processItems(items) {
for (const item of items) {
doWork(item);
if (performance.now() - start > 50) {
await new Promise(r => setTimeout(r, 0)); // yield to main thread
}
}
}Keep DOM under 1500 nodes. Audit with: document.querySelectorAll('*').length.
/* Mobile-first: base styles are mobile (320px+) */
/* Small phones handled by fluid units, no breakpoint needed */
@media (min-width: 480px) { /* Large phones */ }
@media (min-width: 768px) { /* Tablets */ }
@media (min-width: 1024px) { /* Small desktop */ }
@media (min-width: 1280px) { /* Large desktop */ }/* Minimum 44x44px touch targets — WCAG 2.5.8 */
button, a, input, select, textarea {
min-height: 44px;
min-width: 44px;
}
/* Prevent iOS zoom on input focus */
input, select, textarea {
font-size: 16px; /* >= 16px prevents auto-zoom */
}<meta name="viewport" content="width=device-width, initial-scale=1">Hamburger menu for primary nav on mobile. Place critical actions in thumb zone (bottom 40% of screen).
/* Bottom nav for high-frequency actions */
.mobile-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 56px;
display: flex;
justify-content: space-around;
align-items: center;
background: var(--wp--preset--color--base);
box-shadow: 0 -1px 3px rgb(0 0 0 / 0.1);
z-index: 100;
padding-bottom: env(safe-area-inset-bottom);
}
@media (min-width: 768px) {
.mobile-bottom-nav { display: none; }
}:root {
--step--2: clamp(0.64rem, 0.58rem + 0.28vw, 0.80rem);
--step--1: clamp(0.80rem, 0.73rem + 0.35vw, 1.00rem);
--step-0: clamp(1.00rem, 0.91rem + 0.43vw, 1.25rem); /* body */
--step-1: clamp(1.25rem, 1.14rem + 0.54vw, 1.56rem); /* h4 */
--step-2: clamp(1.56rem, 1.43rem + 0.68vw, 1.95rem); /* h3 */
--step-3: clamp(1.95rem, 1.78rem + 0.85vw, 2.44rem); /* h2 */
--step-4: clamp(2.44rem, 2.23rem + 1.07vw, 3.05rem); /* h1 */
}{
"settings": {
"typography": {
"fluid": true,
"fontSizes": [
{ "slug": "small", "size": "clamp(0.80rem, 0.73rem + 0.35vw, 1.00rem)", "name": "Small" },
{ "slug": "medium", "size": "clamp(1.00rem, 0.91rem + 0.43vw, 1.25rem)", "name": "Medium" },
{ "slug": "large", "size": "clamp(1.56rem, 1.43rem + 0.68vw, 1.95rem)", "name": "Large" },
{ "slug": "x-large","size": "clamp(2.44rem, 2.23rem + 1.07vw, 3.05rem)", "name": "Extra Large" }
],
"fontFamilies": [
{ "slug": "brand", "fontFamily": "'Brand', system-ui, sans-serif", "name": "Brand" },
{ "slug": "mono", "fontFamily": "'JetBrains Mono', monospace", "name": "Mono" }
]
}
}
}/* Optimal measure: 45-75 characters */
.entry-content p,
.entry-content li {
max-width: 65ch;
line-height: 1.6;
}
h1, h2, h3 { line-height: 1.2; }
h4, h5, h6 { line-height: 1.3; }// Preload critical fonts in <head>
add_action( 'wp_head', function() {
echo '<link rel="preload" href="' . get_theme_file_uri('fonts/brand.woff2')
. '" as="font" type="font/woff2" crossorigin>' . "\n";
}, 1 );{
"settings": {
"color": {
"palette": [
{ "slug": "primary", "color": "#1a56db", "name": "Primary" },
{ "slug": "secondary", "color": "#6b7280", "name": "Secondary" },
{ "slug": "accent", "color": "#f59e0b", "name": "Accent" },
{ "slug": "base", "color": "#ffffff", "name": "Base" },
{ "slug": "contrast", "color": "#111827", "name": "Contrast" },
{ "slug": "success", "color": "#059669", "name": "Success" },
{ "slug": "warning", "color": "#d97706", "name": "Warning" },
{ "slug": "error", "color": "#dc2626", "name": "Error" }
]
}
}
}/* Use WordPress preset variables everywhere */
.btn-primary {
background: var(--wp--preset--color--primary);
color: var(--wp--preset--color--base);
}
.alert-error {
border-left: 4px solid var(--wp--preset--color--error);
background: color-mix(in srgb, var(--wp--preset--color--error) 8%, white);
}/* Dark mode via media query */
@media (prefers-color-scheme: dark) {
:root {
--wp--preset--color--base: #111827;
--wp--preset--color--contrast: #f9fafb;
}
}Always verify contrast ratios. Minimum 4.5:1 for body text, 3:1 for large text (18px+ or 14px bold) and UI components.
// Output semantic breadcrumbs (works with Yoast, Rank Math, or custom)
function zentratec_breadcrumbs() {
if ( function_exists('rank_math_the_breadcrumbs') ) {
rank_math_the_breadcrumbs();
} elseif ( function_exists('yoast_breadcrumb') ) {
yoast_breadcrumb('<nav aria-label="Breadcrumb">', '</nav>');
}
}Infinite scroll breaks footer access and disables back-button history. Use numbered pagination or "Load More" with URL state.
// Accessible numbered pagination
the_posts_pagination([
'mid_size' => 2,
'prev_text' => '<span aria-label="Previous page">«</span>',
'next_text' => '<span aria-label="Next page">»</span>',
]);.back-to-top {
position: fixed;
bottom: 2rem;
right: 2rem;
opacity: 0;
transition: opacity 200ms ease;
pointer-events: none;
}
.back-to-top.visible {
opacity: 1;
pointer-events: auto;
}const btn = document.querySelector('.back-to-top');
const observer = new IntersectionObserver(([e]) => {
btn.classList.toggle('visible', !e.isIntersecting);
}, { rootMargin: '-300px 0px 0px 0px' });
observer.observe(document.querySelector('header'));All builders (Tatsu, Elementor, WPBakery) share this pattern. Enforce consistent spacing at each level.
/* Consistent section spacing */
.tatsu-section,
.elementor-section,
.vc_section {
padding-block: var(--section-spacing, clamp(3rem, 6vw, 6rem));
}
/* Content width management */
.tatsu-row,
.elementor-container,
.vc_row {
max-width: var(--content-width, 1200px);
margin-inline: auto;
padding-inline: clamp(1rem, 3vw, 2rem);
}Use specificity, not !important. Target the builder's own wrapper classes.
/* Override builder defaults cleanly with specificity */
body .tatsu-section .tatsu-column .tatsu-text-block p {
font-size: var(--step-0);
line-height: 1.6;
max-width: 65ch;
}
/* For Elementor: use the widget wrapper */
.elementor-widget-text-editor .elementor-widget-container p {
font-size: var(--step-0);
}<picture>
<source media="(min-width: 768px)"
srcset="wide-800.webp 800w, wide-1200.webp 1200w, wide-1600.webp 1600w"
sizes="(min-width: 1280px) 1200px, 100vw"
type="image/webp">
<source srcset="square-400.webp 400w, square-600.webp 600w"
sizes="100vw" type="image/webp">
<img src="fallback-800.jpg" alt="Descriptive alt text"
width="800" height="600" loading="lazy" decoding="async">
</picture>loading="lazy", YES fetchpriority="high"loading="lazy", YES decoding="async"content-visibility: auto on the container// Generate inline low-quality placeholder
function get_lqip_style( $attachment_id ) {
$thumb = wp_get_attachment_image_url( $attachment_id, [32, 32] );
if ( ! $thumb ) return '';
return sprintf(
'background: url(%s) center/cover no-repeat; filter: blur(20px);',
esc_url( $thumb )
);
}<div class="img-wrapper" style="<?php echo get_lqip_style($id); ?>">
<img src="full.webp" loading="lazy" decoding="async"
onload="this.parentElement.style.background='none'"
width="800" height="600" alt="Photo description">
</div><form novalidate>
<div class="field-group">
<label for="email">Email address</label>
<input id="email" type="email" name="email" required
autocomplete="email" inputmode="email"
aria-describedby="email-error"
pattern="[^@]+@[^@]+\.[a-zA-Z]{2,}">
<p id="email-error" class="field-error" role="alert" hidden>
Enter a valid email address
</p>
</div>
</form>// Inline validation on blur, not on input
document.querySelectorAll('input[required]').forEach(input => {
input.addEventListener('blur', () => {
const error = document.getElementById(input.getAttribute('aria-describedby'));
if (!error) return;
const invalid = !input.validity.valid;
error.hidden = !invalid;
input.setAttribute('aria-invalid', String(invalid));
});
});/* Progress indicator */
.form-steps {
display: flex;
gap: 0.5rem;
counter-reset: step;
}
.form-step { counter-increment: step; }
.form-step::before {
content: counter(step);
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--wp--preset--color--secondary);
color: white;
font-weight: 700;
}
.form-step[aria-current="step"]::before {
background: var(--wp--preset--color--primary);
}Always set: name, email, tel, street-address, postal-code, country, cc-number, cc-exp, cc-csc, given-name, family-name, organization.
Skeletons preserve layout and feel faster than spinners. Use them for content regions.
.skeleton {
background: linear-gradient(90deg,
var(--wp--preset--color--base) 25%,
color-mix(in srgb, var(--wp--preset--color--contrast) 8%, transparent) 50%,
var(--wp--preset--color--base) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer { to { background-position: -200% 0; } }
.skeleton-text { height: 1em; margin-bottom: 0.75em; }
.skeleton-text:last-child { width: 60%; }
.skeleton-image { aspect-ratio: 16 / 9; }cubic-bezier(0.4, 0, 0.2, 1) for standard, cubic-bezier(0, 0, 0.2, 1) for decelerationasync function wpAjaxLoad(action, container) {
container.setAttribute('aria-busy', 'true');
container.innerHTML = '<div class="skeleton skeleton-text"></div>'.repeat(3);
try {
const res = await fetch(wpApiSettings.root + action, {
headers: { 'X-WP-Nonce': wpApiSettings.nonce }
});
if (!res.ok) throw new Error(res.statusText);
container.innerHTML = await res.text();
} catch (err) {
container.innerHTML = '<p class="error-message">Failed to load. <button onclick="wpAjaxLoad(\'' + action + '\', this.closest(\'[aria-busy]\'))">Retry</button></p>';
} finally {
container.removeAttribute('aria-busy');
}
}Every 404 must include: (1) clear "not found" message, (2) search form, (3) popular/recent content links, (4) link back to homepage.
// 404.php template
get_header(); ?>
<main class="error-404" role="main">
<h1><?php esc_html_e('Page not found', 'theme'); ?></h1>
<p><?php esc_html_e('The page you requested does not exist or has moved.', 'theme'); ?></p>
<?php get_search_form(); ?>
<h2><?php esc_html_e('Popular pages', 'theme'); ?></h2>
<ul>
<?php
$popular = new WP_Query(['posts_per_page' => 5, 'orderby' => 'comment_count']);
while ($popular->have_posts()) : $popular->the_post(); ?>
<li><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></li>
<?php endwhile; wp_reset_postdata(); ?>
</ul>
<a href="<?php echo esc_url(home_url('/')); ?>"><?php esc_html_e('Back to homepage', 'theme'); ?></a>
</main>
<?php get_footer();Never clear valid fields on error. Scroll to the first error. Announce errors to screen readers with role="alert".
// Fallback when external service fails
function get_external_data() {
$cached = get_transient('external_data');
if ($cached !== false) return $cached;
$response = wp_remote_get('https://api.example.com/data', ['timeout' => 5]);
if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
$stale = get_option('external_data_fallback', []);
return $stale; // serve stale data rather than fail
}
$data = json_decode(wp_remote_retrieve_body($response), true);
set_transient('external_data', $data, HOUR_IN_SECONDS);
update_option('external_data_fallback', $data);
return $data;
}Match WordPress admin styling. Use the Settings API.
add_action('admin_menu', function() {
add_options_page('Brand Settings', 'Brand', 'manage_options', 'brand-settings', 'render_brand_settings');
});
add_action('admin_init', function() {
register_setting('brand_group', 'brand_primary_color', ['sanitize_callback' => 'sanitize_hex_color']);
add_settings_section('brand_colors', 'Color Settings', null, 'brand-settings');
add_settings_field('primary_color', 'Primary Color', function() {
$val = get_option('brand_primary_color', '#1a56db');
echo '<input type="color" name="brand_primary_color" value="' . esc_attr($val) . '">';
}, 'brand-settings', 'brand_colors');
});
function render_brand_settings() {
echo '<div class="wrap"><h1>Brand Settings</h1><form method="post" action="options.php">';
settings_fields('brand_group');
do_settings_sections('brand-settings');
submit_button();
echo '</form></div>';
}// Dismissible success notice
add_action('admin_notices', function() {
if (!get_transient('brand_saved')) return;
delete_transient('brand_saved');
echo '<div class="notice notice-success is-dismissible"><p>Settings saved.</p></div>';
});Use notice-success, notice-error, notice-warning, notice-info. Always add is-dismissible for non-critical messages.
Run this checklist before any page is considered complete.
| Metric | Target | How to Verify |
|---|---|---|
| LCP | < 2.5s | Lighthouse, CrUX, web-vitals JS lib |
| CLS | < 0.1 | Lighthouse, Layout Instability API |
| INP | < 200ms | CrUX, web-vitals JS lib |
| Above-fold render | < 1s | WebPageTest filmstrip |
| Time to Interactive | < 3s | Lighthouse |
| DOM nodes | < 1500 | document.querySelectorAll('*').length |
| Font display | No FOIT/FOUT | Network tab, font-display: swap |
| Scroll performance | 60fps | DevTools Performance panel |
| Touch response | < 100ms | INP measurement |
| Image dimensions | All set | img:not([width]) selector audit |
| Lazy loading | Below-fold only | Verify first image has no lazy attr |
| Critical CSS inlined | Above-fold CSS | View source, check <style> in head |
// Paste in DevTools console
(() => {
const imgs = document.querySelectorAll('img:not([width]), img:not([height])');
const bigDOM = document.querySelectorAll('*').length;
const noAlt = document.querySelectorAll('img:not([alt])');
const smallTargets = [...document.querySelectorAll('a, button')].filter(el => {
const r = el.getBoundingClientRect();
return r.width < 44 || r.height < 44;
});
console.table({
'Images missing dimensions': imgs.length,
'DOM nodes': bigDOM,
'Images missing alt': noAlt.length,
'Touch targets < 44px': smallTargets.length,
});
})();When reviewing WordPress code, flag violations of these standards:
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.