Skip to content

mattgregory-dev/mg-wp-starter-theme

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Custom WordPress Theme

A custom, performance-first WordPress theme. Built to be clean, accessible, and easy to maintain.

Architectural decisions and rationale: see ARCHITECTURE.md. Read it before forking the starter for a new project, or before changes that affect the boundary between theme, plugin, and content.

Overview

  • Custom WordPress theme with purpose-built page templates and content flows.
  • Fast-loading UI with a minimal JavaScript footprint.
  • Responsive navigation and interactive elements.

Approach

This theme is built without a page builder (Elementor, Divi, etc.). That is a deliberate choice with trade-offs.

Building directly in PHP, Sass, and Tailwind gives full control over the markup and the asset pipeline. Designs can be more elaborate, layouts can be tuned per page, and the resource footprint stays small because nothing is loaded that isn't used. Custom page templates are used as one-offs — applied directly to specific pages — rather than as reusable templates in the traditional WordPress sense. This is the most direct way to apply bespoke design on a per-page basis. Gutenberg is reserved for basic content pages where editor flexibility matters more than design fidelity.

The trade-off: this approach assumes a developer maintains the site and can run the build environment. It suits projects with custom design requirements and the budget to support them. A page-builder approach is a better fit for projects optimized for non-developer editing or tighter budgets.

Performance

The theme performs in the top percentiles across standard web performance metrics. The main contributors:

  • Minimal front-end JavaScript footprint.
  • Optimized build pipeline with Vite, PostCSS, and Tailwind's purge step.
  • Static, hand-authored markup rather than page-builder output.

Tech Stack

  • WordPress (custom theme)
  • Vite, Sass, Tailwind, PostCSS
  • theme.json (block editor presets + global styles)
  • Forminator (forms)
  • Yoast SEO / Rank Math (SEO — bring your own; see SEO)

Project Structure

.
├── src/                          # Source files (edit here)
├── dist/                         # Compiled output (do not edit)
│   ├── assets/
│   └── tailwind.css
├── icons/                        # Inline-SVG icons (one .svg per icon)
├── inc/                          # PHP modules wired by functions.php
│   ├── theme-setup.php           # Theme supports, menus, textdomain
│   ├── enqueue.php               # Asset enqueue (Vite dev / dist build)
│   ├── preload.php               # Critical font preload <link> tags
│   ├── icons.php                 # theme_icon() / theme_the_icon() helpers
│   ├── widgets.php               # Sidebar registration
│   ├── branding.php              # Login screen overrides
│   ├── cpt.php                   # Custom post type scaffold (commented)
│   ├── attachments.php           # Strip auto-titles from media uploads
│   ├── images.php                # Image-quality + SVG-upload scaffold
│   ├── heartbeat.php             # Throttle WP heartbeat
│   ├── security.php              # XML-RPC, login errors, REST hardening
│   ├── comments.php              # Comments disabled globally
│   ├── analytics.php             # GA4 scaffold (off until constant defined)
│   └── bloat.php                 # Strip emoji/oembed/generator/etc.
├── page-*.php                    # Custom page templates (one-offs)
├── functions.php                 # Module manifest only — `require_once` calls
├── theme.json                    # Block editor presets + global styles
├── vite.config.js
└── tailwind.config.cjs

functions.php is a manifest of require_once calls. To disable a module, comment out its line — every file is self-contained.

Local Development

  1. npm install
  2. npm run dev:all
  3. Set CUSTOM_WP_VITE_DEV to true in wp-config.php to load the dev server.
define('CUSTOM_WP_VITE_DEV', true);

Production Build

  1. npm run build
  2. WordPress enqueues:
    • dist/tailwind.css
    • dist/assets/main.css
    • dist/assets/main.js

Forking the starter for a new project

The starter is meant to be cloned. There are two slug-coupled values that must be updated together when you fork it; missing either causes hard-to-spot breakage (translations stop loading, package managers complain).

The two slug-coupled values:

  1. The theme directory name in wp-content/themes/.
  2. The text-domain ('mgn' literal, used by every __(), _e(), esc_html_e(), esc_attr_e() call).

Checklist

Substitute mgn<newslug> everywhere below. Use a project-wide find/replace tool, or sed. The slug should match the new theme directory name.

Required:

  • Rename the theme directory: wp-content/themes/mgnwp-content/themes/<newslug>.
  • Update style.css Theme Name header (currently Barebones Starter Theme) to reflect the project.
  • Find/replace 'mgn''<newslug>' across all .php files (text-domain in i18n calls). Affected files: every template, every partial, every inc/*.php. ~15 files.
  • Update inc/theme-setup.php line 7: load_theme_textdomain( 'mgn', ... )load_theme_textdomain( '<newslug>', ... ).
  • Update package.json "name": "mgn""name": "<newslug>".
  • Update composer.json "name": "mgn/theme""name": "<newslug>/theme".
  • Update phpcs.xml.dist <element value="mgn"/><element value="<newslug>"/> (text-domain whitelist).
  • Run npm install to regenerate package-lock.json against the new package name.
  • Run composer install to regenerate composer.lock against the new package name.
  • Run npm run build and reload the site. Confirm at least one translatable string still appears (translation lookup is silent fallback when the text-domain is wrong, so confirm visually or by adding a .mo file for the new domain).

Optional (project polish, not breakage-causing):

  • Update AGENTS.md references (paths, examples).
  • Update README.md and ARCHITECTURE.md to reflect the new project name (or delete and rewrite for the project).
  • Update screenshot.webp with the new project's hero/preview.
  • Update partials/logo.php if you're not using the_custom_logo() from the Customizer.
  • Update footer.php copyright text if you're not using bloginfo('name').
  • Rename example plugin names in ARCHITECTURE.md (mgn-events, mgn-projects) to your project's pattern.

One-shot find/replace command

If you're confident, you can do most of the required substitutions in one pass from the theme root:

# Replace text-domain in all PHP, plus package.json, composer.json, phpcs.xml.dist
find . -type f \( -name '*.php' -o -name 'package.json' -o -name 'composer.json' -o -name 'phpcs.xml.dist' \) \
  ! -path './node_modules/*' ! -path './vendor/*' ! -path './dist/*' \
  -exec sed -i 's/mgn/<newslug>/g' {} +

Review the diff before committing — sed will catch every occurrence of mgn including inside comments, brand names, and example references. Inspect for over-replacement.

Why not parameterize this?

WordPress conventions require literal text-domains. Storing 'mgn' in a variable and passing it everywhere works against WP's static analysis tools (PHPCS i18n sniffs flag non-literal text-domains as errors). Hard-coded literals are the standard.

The trade-off: ~15 files to find/replace once per fork. Document the slug in your project README so future-you remembers what it is.

Block Editor Integration

A theme.json ships with the theme, defining the color palette, font families, layout sizes, and link styling that the block editor uses. The same compiled stylesheets enqueued on the front-end are registered as editor styles via add_theme_support('editor-styles') + add_editor_style(), so blocks render with your design tokens both in the editor and on the live site.

If you change a token in src/styles/_layout.scss (e.g., a brand color), update the matching value in theme.json so the editor presets stay in sync. They are intentionally separate sources because Sass cannot be evaluated by theme.json's loader and vice versa.

Color picker is restricted to the theme palette (color.custom: false) so editor users can't introduce off-brand colors. To allow custom colors, flip to true in theme.json.

Widget Areas

Two widget areas are registered in theme_setup():

  • Primary Sidebar (primary) — call get_sidebar() from any template that should display it. The sidebar.php template is in place and self-hides if no widgets are assigned.
  • Footer Widgets (footer) — call dynamic_sidebar('footer') from footer.php if you want a widgetized footer block.

Custom Post Types

Build CPTs as plugins, not in the theme. Custom post types register data that should outlive the active theme. A CPT registered in the theme disappears the moment a new theme is activated — existing posts become unqueryable ghosts in the database, admin can't see them, and front-end URLs 404. The client loses access to their content.

The recommended pattern:

Layer Lives in Contains
Data + admin Plugin (e.g., mgn-events, mgn-projects) register_post_type, taxonomies, ACF / Carbon Fields registration, admin column tweaks, pre_get_posts filters, REST/GraphQL exposure
Presentation This theme (or a project-specific fork) single-{type}.php, archive-{type}.php, partials/{type}-card.php, type-specific SCSS, block patterns

A typical events plugin structure mirrors this theme's inc/ pattern: mgn-events.php bootstrap + inc/cpt.php, inc/taxonomy.php, inc/fields.php, inc/rest.php, acf-json/ for field-group exports. Same conventions, separate distribution.

Headless implications. With CPTs in a plugin and 'show_in_rest' => true on registration, the data layer is queryable from any front-end (PHP templates, Next.js, Astro, etc.). The plugin doesn't care which renderer consumes it. Re-skin or replace the front-end without touching the data.

The scaffold in inc/cpt.php. A commented register_post_type example lives there for two narrow cases: throwaway prototyping, or a CPT that's genuinely theme-locked and will never outlive the build. For anything production, build a plugin. The file's header comment carries the same warning.

Security Hardening

inc/security.php ships defense-in-depth defaults:

  • XML-RPC disabled via xmlrpc_enabled filter — common brute-force surface, rarely needed by modern sites. Re-enable for Jetpack or remote publishing by removing the filter.
  • Login errors masked to a generic "Invalid login. Please try again." — default WP returns "the username admin is incorrect" / "the password is wrong", both leaking information about whether a username exists.
  • REST /wp/v2/users endpoints removed — by default these are publicly readable and expose every author's username. Strips /users and /users/(?P<id>[\d]+) from rest_endpoints.
  • ?author=N enumeration blocked — WordPress redirects /?author=1 to /author/{username}/, leaking the slug. The starter redirects any ?author query on the front-end to home.

Comment out specific filters in inc/security.php if you need any of these behaviors back (e.g., Jetpack needs XML-RPC, the WP REST API users endpoint may be needed for headless setups).

Comments

Comments are disabled globally by inc/comments.php. The module:

  • Forces comments_open() and pings_open() to false everywhere.
  • Empties any existing comment lists from the front-end.
  • Removes comment support from the post and page post types.
  • Hides the Comments admin menu, admin bar item, and redirects edit-comments.php.

To re-enable comments site-wide, set define('THEME_ENABLE_COMMENTS', true); in wp-config.php, or comment out the require_once for inc/comments.php in functions.php. The comments.php template is in place either way — it'll start rendering as soon as comments are re-enabled.

Image Handling

inc/images.php tunes two upload defaults:

  • big_image_size_threshold raised from 2560 → 3000px, so retina-density source images survive their initial scale-down pass.
  • jpeg_quality raised from 82 → 85, slightly higher quality at minor file-size cost.

LCP hint. The first attachment image rendered inside the main loop on a singular page is automatically promoted to loading="eager" + fetchpriority="high". This overrides WordPress's auto-heuristic, which otherwise can mis-assign priority to the wrong early image (logo, avatar, nav icon) on a layout where those appear above the hero.

The filter only affects images flowing through wp_get_attachment_image() / the_post_thumbnail() / wp_get_attachment_image_attributes. Templates that render hand-written <img> tags as the LCP element should set loading="eager" and fetchpriority="high" on the tag manually.

Hero sections (LCP)

For hero/banner sections at the top of a template, prefer an <img> positioned absolutely over a CSS background-image. The <img> route gets the LCP filter, srcset/sizes, alt text, and the browser's preload scanner — all for free. CSS background-image skips every one of those.

Recommended pattern:

<section class="hero relative">
  <?php
  echo wp_get_attachment_image(
    get_post_thumbnail_id(),
    'full',
    false,
    array(
      'class'    => 'hero-bg',
      'sizes'    => '100vw',
    )
  );
  ?>
  <div class="hero-content relative z-10">
    <h1><?php the_title(); ?></h1>
  </div>
</section>
.hero {
  position: relative;
  min-height: 60vh;
  overflow: hidden;
}
.hero-bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  z-index: -1;
}
.hero-content {
  position: relative;
  z-index: 1;
}

The LCP filter in inc/images.php auto-applies loading="eager" + fetchpriority="high" because the call goes through wp_get_attachment_image() on a singular page in the main loop. No template-level work needed.

When CSS background-image is unavoidable (parallax effects, layered overlays, art-direction tricks an <img> can't reproduce), the browser only discovers the URL after parsing the CSS file — that's a delayed LCP. Emit a preload hint:

add_action( 'wp_head', function () {
  if ( ! is_singular() ) return;
  $hero_id = get_post_thumbnail_id();
  if ( ! $hero_id ) return;
  $url = wp_get_attachment_image_url( $hero_id, 'full' );
  if ( ! $url ) return;
  printf(
    '<link rel="preload" as="image" href="%s" fetchpriority="high">',
    esc_url( $url )
  );
}, 1 );

Rule of thumb: if the hero is purely visual with content overlaid, use the <img> pattern. Use the preload hint only when the design genuinely needs CSS-only background effects.

SVG uploads are blocked by default. SVGs can carry inline JavaScript that runs when the file is rendered — uploading an unsanitized SVG is an XSS vector. inc/images.php ships a commented-out upload_mimes filter for SVG, with explicit instructions: install Safe SVG (10up plugin) for automatic sanitization, or composer require enshrined/svg-sanitize and run sanitization in a wp_handle_upload_prefilter. Do not uncomment the bare allow-list without one of those paths in place.

Icons

The theme uses inline SVG icons, not an icon font. Files live in icons/ at the theme root, one .svg per icon. Render via the helper in inc/icons.php:

<?php theme_the_icon( 'angle-up' ); ?>                       // echoes
<?php echo theme_icon( 'angle-up', array(                    // returns; overrides defaults
  'class'      => 'icon icon-angle-up big',
  'aria-label' => 'Scroll up',
  'aria-hidden' => false,
) ); ?>

Defaults applied to every icon: class="icon icon-<name>", aria-hidden="true", focusable="false". Override per call via the $args array.

Why inline SVG instead of an icon font:

  • No font-loading FOUT — icons paint as soon as HTML parses.
  • ~150 bytes per icon vs. hundreds of KB for a webfont's full glyph table — only ship what you use.
  • fill="currentColor" inherits text color via CSS naturally.
  • Better accessibility (SVGs play more cleanly with screen readers than ::before icon-font glyphs).
  • No build step for icons; no copy script; no theme-name-hardcoded font path.

Adding an icon:

  1. Source from FontAwesome free SVG download, lucide.dev, heroicons.com, tabler.io/icons, or any other SVG library.
  2. Save as icons/<name>.svg (kebab-case).
  3. Open and scrub editor garbage (<defs> you don't need, <metadata>, weird namespaces, hardcoded colors). Set fill="currentColor" on the <svg> or <path>. Drop any width/height attributes — let CSS size them.
  4. Call theme_the_icon( '<name>' ) in your template.

CSS Units

The decision rule: "if a user bumps their browser font-size to 24px, should this thing get proportionally bigger?" Yes → rem. No, this is a fixed UI detail → px. It should track the element's type → em.

rem is the default for typography, spacing, padding, margins, max-widths, gap, top/right/bottom/left offsets — anything that should scale with user font-size preferences. This matches Tailwind's spacing scale (p-4 = 1rem padding), so SCSS and Tailwind read the same way.

px is correct for fixed UI details:

  • Borders and outlines (1px, 2px hairlines) — fractional rems for borders look weird and don't survive sub-pixel rendering.
  • Box-shadow offsets and blur radii.
  • Outline-offset, focus-ring offset.
  • SVG icon strokes.
  • Specific pixel-precise dimensions (hamburger menu line width, drawer-stripe heights, fixed caps inside min(320px, 88vw)).
  • Media query breakpoints (Tailwind uses px; match it).
  • The html { font-size: 17px } anchor in tailwind.css — this is the rem base; don't change to rem.

em for component-scoped scaling. When something should track the element's own font-size rather than the root:

.button {
  padding: 0.5em 1em;       // tracks the button's text size
  border-radius: 0.25em;
}

A .button.big { font-size: 1.5rem } then auto-scales padding and radius without a separate rule. Right tool for buttons, badges, chips, anything with size variants. Use sparingly outside that case — em compounds when nested.

%, vw, dvh, fr, minmax() for layout sizing where fluid behavior matters. Prefer dvh over vh for full-viewport heights — dvh (dynamic viewport height) handles iOS Safari's address-bar shrinkage correctly.

Antipatterns:

  • px for typography. Locks out user font-size scaling entirely. Always rem.
  • Mixing px and rem inconsistently for the same property type across components — predictability suffers.
  • Manual rem-to-px math. The html base is 17px, not 16px. If you need exactly 16px, write 16px; if you want proportional scaling, accept the rem result.

The same rules in scannable form for agents: see AGENTS.md → Unit Conventions.

Performance

Several optimizations are baked in by default:

  • Font preloadinc/preload.php emits <link rel="preload"> tags for the body and heading fonts at wp_head priority 1. Without this, the browser only discovers @font-face URLs after parsing the main stylesheet — preloading saves 100-300ms of LCP on first visit. The list is curated (Lato 400 + Marcellus 400 by default); add new entries when introducing above-the-fold fonts, remove unused ones — preloading bytes you don't render is net negative.
  • Tailwind minificationpostcss.config.cjs runs cssnano on the production Tailwind build. Dev mode uses the Tailwind CLI directly and bypasses PostCSS, so dev iteration stays fast.
  • JS deferredtheme-main enqueues with 'strategy' => 'defer' and is an ES module (deferred by spec).
  • CSS conditional load — block library CSS (~30KB) only loads on singular pages with block content; archives/search/PHP-only templates skip it. See Stripped WP Defaults.
  • LCP image hint — first wp_get_attachment_image() on singular pages auto-promotes to loading="eager" + fetchpriority="high". See Image Handling.
  • Inline SVG icons — saves ~250-320KB vs the FontAwesome webfont it replaced. See Icons.
  • Bloat strip — emoji JS, generator meta, oEmbed, classic-theme-styles, dns-prefetch hints, etc. See Stripped WP Defaults.

Updating the font preload list

inc/preload.php lists the woff2 files to preload. The filenames assume Vite isn't hashing assets (it's configured [name][extname] with no [hash] in vite.config.js). After running npm run build, the file at dist/assets/lato-latin-400-normal.woff2 is the file the preload tag points at — keep them in sync.

When adding a new font weight or family:

  1. Run npm run build and check dist/assets/ for the actual filename produced.
  2. Add it to the $fonts array in inc/preload.php only if it's used above the fold.
  3. Don't preload unused weights — wastes bandwidth and competes with critical resources.

Analytics

inc/analytics.php ships a Google Analytics 4 (gtag.js) scaffold. Active by default but no-ops unless THEME_GA_MEASUREMENT_ID is defined in wp-config.php:

define( 'THEME_GA_MEASUREMENT_ID', 'G-XXXXXXXXXX' );

Once defined, GA fires on every front-end page load except:

  • Vite dev mode (CUSTOM_WP_VITE_DEV true) — no analytics from local development.
  • Logged-in users — admins clicking around shouldn't pollute stats.

Performance details:

  • Emits <link rel="preconnect"> to googletagmanager.com and google-analytics.com so TLS handshakes start before the gtag.js fetch fires (~100-300ms saved on first event).
  • Loads gtag.js with async so it doesn't block parsing or rendering.
  • Hooked at wp_head priority 5 — runs before stylesheets and font preloads, giving preconnect the maximum head start.

To use a different analytics provider (Plausible, Fathom, Matomo, Mixpanel), this file is a template — same shape, swap the URLs and snippet for the provider's docs.

Heartbeat

inc/heartbeat.php reduces WordPress heartbeat traffic:

  • Throttle interval from 15 seconds (editor default) to 60 seconds site-wide.
  • Disable the heartbeat script entirely on the front-end (admin still uses it for autosave and post-locking).

Stripped WP Defaults

The theme dequeues several WordPress defaults that aren't useful here. All live in functions.php under "Strip WordPress Bloat" — comment out individual add_action/add_filter lines to restore any.

Stripped Why
Emoji JS polyfill (print_emoji_detection_script) Modern OSes render emoji natively; the polyfill is for IE/legacy.
Emoji styles (print_emoji_styles) Same.
s.w.org dns-prefetch Only used by the emoji polyfill.
<meta name="generator"> Mild info leak, no functional value.
oEmbed auto-embed + wp-embed script Pasted URLs render as plain links. Use raw <iframe> in a Custom HTML block for occasional embeds.
classic-theme-styles Default <button> rules; our SCSS supplies its own.
wp-block-library + wp-block-library-theme (conditional) Loaded only on singular pages where has_blocks() is true. Archives, search results, and PHP-only templates skip the ~30KB.

SEO

The theme ships no SEO meta tags by default — <title> is generated by add_theme_support('title-tag') and that's it. Most projects install a plugin for OG/Twitter cards, sitemaps, schema, and per-post controls:

  • Yoast SEO — most ubiquitous, best UI for non-developer editors.
  • Rank Math — leaner, more configurable, free tier ships more features than Yoast's.
  • The SEO Framework — minimal, no upsells, no analytics tracking.

Pick one. Adding your own meta-tag function in functions.php is fine for a marketing site with predictable pages, but a plugin will pay off the moment editors need per-post overrides.

Internationalization

The theme is i18n-ready. load_theme_textdomain() is wired in theme_setup() and the text-domain is mgn (matches the theme slug). All translatable strings in templates and partials use __(), _e(), or esc_*_e() against this domain.

To add a translation, drop a .po / .mo pair into languages/ named mgn-{locale} (e.g., mgn-fr_FR.mo). WordPress loads it automatically based on the site language.

If you fork this theme to a new project and rename the directory, do a project-wide replace of the 'mgn' text-domain string to match the new slug.

Accessibility

The starter ships the bones of an accessible theme. Treat the second list as a checklist when extending it — the original theme this codebase descends from had most of these stripped, and they are easy to forget.

What's already wired:

  • Semantic landmarks: <header>, <main id="main-content">, <footer>. Every <nav> carries a unique aria-label (Primary, Mobile menu, Footer).
  • wp_body_open() runs immediately after <body> so plugins can inject correctly.
  • <title> is generated via add_theme_support('title-tag').
  • Skip linkSkip to main content is the first focusable element, visually hidden until focused (offsets itself below the WP admin bar when present).
  • Hamburger toggle declares type="button", aria-label, aria-controls="mobile-menu", and an initial aria-expanded="false". The drawer carries the matching id="mobile-menu". JS keeps aria-expanded and the drawer's aria-hidden in sync.
  • Drawer focus management — opening the drawer moves focus to the first focusable element inside it, traps Tab navigation while open, and restores focus to the toggle that opened it on close. Escape and overlay-click both close. focus({ preventScroll: true }) is used so closing doesn't jump the page when the sticky toggle is the source.
  • Focus styles:focus-visible outlines on hamburger, drawer close, back-to-top, and skip link. No outline: none is left without a replacement.
  • The back-to-top button has aria-label; the inline SVG icon inside it is aria-hidden="true" so screen readers announce the button once, not twice. The theme_icon() helper applies this default automatically.

What you should still add before going to production:

  • Heading hierarchy. One <h1> per page (post title or page title). Don't skip levels going deeper.
  • Editor discipline. WordPress media uploads should always have alt text. The theme leaves the_post_thumbnail() and content images alone, so the burden is on whoever uploads.
  • Form labels. Any custom forms outside searchform.php should have explicit <label> elements (or aria-label) tied to inputs.
  • Live regions for any dynamic content updates you add (form errors, AJAX status, etc.) — aria-live="polite" for non-urgent, aria-live="assertive" for urgent.

Reduced Motion

Both CSS and JS animations honor prefers-reduced-motion: reduce:

  • CSS — sticky-header transitions are explicitly disabled in src/styles/_header.scss.
  • JS — scrollToTop() falls back to behavior: 'auto', and the preloader hides instantly instead of fading. Both check matchMedia('(prefers-reduced-motion: reduce)').matches at runtime.

When adding new motion (GSAP timelines, scroll-triggered effects, parallax), mirror the pattern:

const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduceMotion) {
  // short-circuit to non-animated path
  return;
}

Evaluate matchMedia at the top of the animation function (not module-level) so users who change the OS preference mid-session are honored without a reload.

Repository Conventions

  • Sensitive values are stored as PHP constants and are not committed to the repository.
  • An AGENTS.md file is included for AI-assisted development workflows (Codex, Claude Code, and similar tools).

Support

For changes to layout, design, or behavior, work from src/ and rebuild. For content edits, use the WordPress admin editor and menus.

About

Modern WordPress starter theme: Vite + Tailwind, modular PHP architecture, content-as-data content modeling, headless-portable. Full linter coverage (ESLint/Stylelint/PHPCS+WPCS), accessibility baked in, ~150-line architecture doc.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors