GreenLight site builder
Turn a design into a finished, launch-ready WordPress site without touching the block editor. Everything, styles, pages, header, footer, forms, SEO, is pushed over REST, so the whole build is scriptable, reviewable, and repeatable.
Before anything else: check for updates
Always run python scripts/check_update.py at the start of every session that uses
this skill, and always tell the user the result — one line when the copy is current
(greenlight skill is up to date (<sha>)), and a clear stop when it is not.
If it reports an update: stop and tell the user before doing anything else — say
their local copy is out of date, give them the exact command it prints
(git -C <skill dir> pull), and ask whether to update now before continuing. Do not
silently build on a stale copy: the API behaviour recorded here changes with each
GreenLight/GreenShift release, and an old skill produces confidently wrong markup.
If the check itself cannot run (no git, offline), say that too, so the user knows the copy is unverified rather than assuming it is current.
Then, if a .env is present, check the site: python scripts/stylebook.py check.
It detects a site built by an older skill version — shell classes missing from the
stylebook, retired gt-section / gt-container names still stored, or pages whose sections
carry a bare wp-section class with no layout of their own. Those sections render
full-width and un-centred. Always relay the result to the user. When it fires, offer
the two fixes it prints: stylebook.py push restores layout on the existing pages
immediately; regenerating the pages makes those sections inspector-editable. Do not build
on the site until the user has chosen.
Two engines, one set of calls
scripts/blocks.py emits Gutenberg markup against either engine, from identical generator
code:
greenshift (default) |
core |
|
|---|---|---|
| Emits | wp:greenshift-blocks/element |
wp:group, wp:heading, wp:paragraph, wp:list, wp:image, wp:buttons |
| Needs | GreenLight builder | nothing |
| Styling | per-block CSS compiled server-side | stylebook classes and theme.json |
| Block id class | gsbp-xxxxxxx (GreenLight targets it) |
gl-xxxxxxx |
Pick with set_backend('core') or GREENLIGHT_BACKEND=core. GreenLight stays the default
because it is what existing builds use.
The difference that matters: core blocks cannot carry arbitrary CSS. The core backend translates the spacing, colour and typography subset core genuinely supports, and refuses anything else with a message telling you to put it in a stylebook class. That is already what this skill tells you to do with breakpoints, so a generator written to the house style ports with little friction, and one that scatters inline CSS will tell you exactly where.
A few things have no core equivalent and raise rather than degrade silently: raw <svg>
icons, background images on sections, and arbitrary tags like <nav>. Each error names the
alternative. Needing several of them is a good reason to stay on GreenLight.
If a greenshift-blocks skill is available, read it first for block-format basics. This
skill covers the site-level pipeline and the API behaviour that is not documented anywhere.
Before you change anything about block emission, read the specification itself.
python scripts/upstream.py sync # clone it, pin the commit
python scripts/upstream.py show CSSRender # grep the real docs
python scripts/upstream.py show -f validate-styles.md
python scripts/upstream.py check # has upstream moved since the pin?
That clones WPsoul's greenlight-vibe into reference/upstream/ so you read primary text.
This is not optional caution. An earlier version of this file described the format from web
summaries. The summaries got CSSRender wrong, inverted the pages-versus-templates
contract, omitted dynamicGClasses and the stylemanager block completely, and listed a
file that does not exist while missing three that do. A summary of a specification is not a
specification.
reference/upstream-block-spec.md is now only a divergence register: where this skill
departs from the spec, and why. The spec itself lives upstream and is fetched on demand.
Before you emit a single section, read reference/fse-and-greenshift.md (FSE widths,
inspector backgrounds, variations) and reference/design-sources.md (Penpot / Paper /
Pencil / Figma / screenshot → blocks). Most failed builds ignored those two files and
copied board width onto a section.
reference/site-conventions.md covers the decisions that are not GreenLight-specific,
typography measure, link and title hygiene, safe updates, handover. Read it before writing
page content; most of it is invisible until it is expensive.
Five rules that cause most failures
Rules 1-3 are GreenLight-backend specific. Rules 4-5 apply to both.
CSS delivery depends on where the markup is going, and the two paths never mix. Upstream splits it (
instructions/validate-styles.md, itsSKILL.md:259):Target Contract patterns, template parts, templates "CSSRender": "1"on every block withstyleAttributesordynamicGClassespages, posts, custom post types no CSSRender; the page's whole CSS goes into the _gspb_post_cssmeta as one stringThe value is the string
"1", not a boolean. A boolean satisfies the PHP renderer, which is exactly why the wrong value survived here unnoticed. REST-pushed blocks never pass through the editor, so nothing compiles their CSS; pick the wrong half of this contract and the page renders unstyled.Do not re-emit the shell per block.
.wp-sectionand.wp-content-wrapare styled once, site-wide, by the stylebook:reference/starter-tokens.jsonships both rules andstylebook.py pushinstalls them. Nothing in the theme or the plugin styles these classes (checked against Greenlight 2.1 and gl-page-builder 3.3.7; the only occurrence in either is the prompt text inside the editor bundle). Upstream's "use next styles for sections" means the author supplies the rule, and a site-wide rule belongs in the stylebook. The rules read the theme's own tokens (--wp--style--global--wide-size,--wp--custom--spacing--side), so the theme still controls the values and the skill carries no width of its own.section()only sets vertical padding and optional background. Pasting the shell CSS into a page stylesheet, or copying it onto every section asstyleAttributes, is the usual source of CSS the design never asked for;compile_css()strips those duplicates. If sections render full-width and un-centred, the stylebook has not been pushed.Push a page with
WP.push_page(), nothing else. WordPress has no file write from this skill. Gutenberg save is what compiles CSS, and REST never goes through Gutenberg.push_page()writes content withclear_css=False, then stores the compiled CSS in_gspb_post_cssvia the pagemetafield (lossless).css_settingsis the fallback because unpatched 3.3.7 ran that endpoint throughsanitize_text_field. A patched copy of this plugin also auto-compilesstyleAttributesinto that meta on REST insert when the content has noinlineCssStyles, so a content-only write still styles. Do not paste into the editor, write PHP/CSS files, use WP-CLI, POST/wp/v2/pages/{id}/meta(that route does not exist), put a<style>incore/html, or add CSSRender on a page.update_page()still clears the stylesheet by default; that is a template-part footgun, not the page path.blocks.set_target('page')omits CSSRender, andblocks.compile_css(markup)builds the stringpush_page()stores. Default istemplate, correct for the header and footer, which are template parts.Responsive arrays work over REST; the constraint is who compiles them.
styleAttributesvalues are four-entry arrays,["desktop","tablet","mobile_landscape","mobile_portrait"], fewer entries applying upward. Verified against GreenLight 2.1 / gl-page-builder 3.3.7: every shape (1 to 4 entries,nullor""gaps,gridTemplateColumns) round-trips intact and the PHP renderer emitsmax-widthrules at 991.98px, 767.98px and 575.98px, smallest entry included. An earlier version of this skill banned multi-value arrays on one unreproduced observation;python scripts/probe_responsive.pyre-runs the check against any site before you trust either claim on a new plugin version.What still constrains you: on a page target nothing server-side compiles them, so
blocks.compile_css()does, mirroring those breakpoints. On the core backend there are no per-block breakpoints at all, so a multi-value array raises and the breakpoint goes in a stylebook class. Shared layout (grids, footer columns) stays in a stylebook class because it is shared, not because arrays are unsafe.Every HTML attribute must be reachable from the block JSON. Not just
data-*,aria-*androle:fetchpriority,decoding,typeand anything else you write into the tag has to appear indynamicAttributestoo. GreenLight rendersclass,href,src,alt,title,width,height,loading,target,reland the media attributes from its own keys, so those are already covered. Everything else is undeclared markup: Gutenberg fails validation, offers "Attempt recovery", and recovery deletes the attribute. Nothing warns you, andverify.pypasses, because the page renders correctly until someone opens it in the editor.An
idis theanchorkey, never an attribute. Passanchor='weddings'and the block emitsid="weddings". A rawidis the most costly version of this bug because recovery strips it and every anchor link pointing at that section silently dies.blocks.pyrefuses a rawidand declares everything outside the rendered set.Build content from element blocks, never raw HTML. A
core/htmlblock is opaque: the client cannot edit a word of it in the editor, it ignores the stylebook so it drifts the moment a token or layout class changes, and every check in this skill skips straight past it. The commonest version of this mistake is hand-writing a grid of cards as one slab of markup.core/htmlis for scripts, JSON-LD and shortcodes. Everything else isblock(),grid(),heading(),image(),section(),columns().Emit inspector fields, not leftover CSS. Never set width on a full-bleed section. GreenLight FSE (
useRootPaddingAwareAlignments, constrainedpost-content) already makesalignfullsections 100% wide. A Penpot/Figma board of 1440px is not a CSS width. Heroes arecontentwrapper+nocolumncontent: flex + gap + padding on the section,var(--wp--style--global--wide-size)on the inner wrap only. Background photos and ambient videos arebackgroundColor/backgroundImageon the section (orbackground_video()/youtubeplay), never a sibling<img>/<video>plus custom CSS. If a developer cannot change it from the GreenShift sidebar, rebuild the band. Details:reference/fse-and-greenshift.md.
scripts/blocks.py enforces 1-4 and refuses section widths. check_blocks.py flags
width on contentwrapper and missing wide-size on nocolumncontent. raw_html()
raises if it is handed content-shaped markup. reference/troubleshooting.md has the
symptom-first list.
Setup
.env in the project root:
WP_URL=https://site.com
WP_USER=admin@example.com
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx # Users -> Profile -> Application Passwords
FIGMA_TOKEN=figd_... # Figma adapter only; Paper needs the desktop MCP, not a token
Verify before building anything:
from scripts.wp_api import WP
print(WP().check()) # expect is_admin True, greenshift True
Every REST call sends the application password on Authorization and again on
X-Greenlight-Authorization (and X-WPVibe-Authorization if that plugin is already
there). Apache CGI/FastCGI often strips the first header; a 401 that looks like "not
logged in" retries once on ?rest_route=. If that still 401s, drop
reference/auth-fallback.php in wp-content/mu-plugins/ so WordPress sees the second
header. _gspb_post_css is written only by WP.push_page() / WP.set_post_css(); a
generic page update silently drops it.
Project layout: input/ design refs · assets/ optimised images · output/ generated
block HTML · reference/ node dumps and media map · scripts/ generators.
Ask the host what it already provides. Managed WordPress usually covers caching, security and backups, do not install plugins that duplicate them.
Design sources
One write path. Penpot, Paper/Pencil, Figma and screenshots only fill a section map.
Read reference/design-sources.md and follow that source's steps. Do not convert
a 1440px board into width: 1440px on a section.
- Penpot (preferred when the file is open in Penpot). MCP
user-penpot: overview, walk top-level frames as sections, export content rasters only. Frame fills becomebackgroundImageon the section, not exported PNGs of the whole band. - Paper / Pencil. Paper Desktop MCP.
get_jsxinline-styles only, never Tailwind. Never read.penfrom disk. - Figma. REST API,
X-Figma-Token. Same intermediate as Paper. - Screenshots / sketches. Last resort. Confirm the section map before generating.
Always produce a section map (hero, features, CTA…) and get it approved before generating. Classify every image as section background, content, or icon.
Layout gotcha: core's flow margin
WordPress core emits :root :where(.is-layout-flow) > * { margin-block-start: 1.6rem }.
Every top-level section after the first inherits 25.6px of margin, which shows up as a
pale seam between full-bleed colour bands. Sections should carry their rhythm as padding,
so zero the margin with a body-prefixed selector in the stylebook:
body .is-layout-flow > * { margin-block-start: 0; }
Specificity 0,1,1 beats core's 0,1,0 because :where() contributes nothing, so this needs
no !important. Any design with adjacent full-width bands hits this.
Fonts
Uploading to the WordPress Font Library is only half the job. WordPress emits @font-face
only for families activated on the global-styles record
(settings.typography.fontFamilies). An installed but unactivated family falls back to a
system font with no error in any log, and every check in this skill still passes.
POST /wp/v2/font-families # family record
POST /wp/v2/font-families/{id}/font-faces # one per weight and style, woff2
POST /wp/v2/global-styles/{id} # activate under settings.typography
Self-host rather than linking Google's CDN. Then assert, because this fails silently:
count @font-face blocks in the rendered page and check it equals the number of faces you
activated, and check fonts.googleapis.com and fonts.gstatic.com appear zero times.
Images
Export at 2x → convert to JPG q82 (keep PNG only for transparency) → cap width 1200, or
2000 for full-bleed backgrounds → upload via POST /wp/v2/media with Content-Type and
Content-Disposition: attachment; filename="…". Record ids and URLs in
reference/media-map.json.
Set the site logo with POST /wp/v2/settings {"site_logo": <id>} and set alt_text on
the attachment, the header logo link has no other accessible name, and its absence is a
failed accessibility audit.
Stylebook first, pages second
POST /wp-json/greenshift/v1/global_settings is the single source of truth for the design
system. Build it before generating pages so pages can simply reference classes.
| Key | Shape | Emitted as |
|---|---|---|
variables |
{"name","variable":"--x","variable_value"} |
body { --x: … } |
colours |
flat array of hex | editor palette, --gs-color0..n |
global_classes |
{"value","label","css"} in the spec; stylebook.py fills in type, attributes, originalID, originalBlock, tag, selectors |
the CSS string verbatim (media queries allowed) |
elements |
list of {"selector","css"} in the spec, emitted as one <prefix>-elements class |
element styles; prefix with body or theme rules that load later win |
The Stylebook admin screen needs the full class shape. It groups classes by
originalBlock and builds a heading from it; an entry without the key crashes the whole
screen with "This block has encountered an error and cannot be previewed", while the front
end renders the same entry fine. stylebook.py push writes the shape the screen itself
creates and repairs existing entries on the next push. Its elements model is an object
keyed by tag with styleAttributes it regenerates CSS from, which free-form element CSS
does not fit, so the spec's elements list becomes a class and renders identically.
Merge semantics: each key you send replaces the stored array. Always read, merge
locally, then write, WP.gs_upsert_classes() and scripts/stylebook.py push do this.
The stylebook prints before the theme's global styles, so a class rule loses every
equal-specificity tie to theme.json that a page stylesheet (printed last) would have won.
Prefix class selectors with body the same way as element styles, body .gt-card{…},
0,1,1 beats 0,1,0 with no !important. Keep rules in the order the design wrote them:
grouping every rule under its first class silently reorders two equal-specificity rules
(.head .small before .dark .small becomes the reverse) and flips a colour. Moving a
site's CSS out of a page into the stylebook was verified with a computed-style diff of
every element on the rendered page, which is the only check that catches this.
On the core backend there is no GreenLight to hold the classes, but the markup still
references them, so they have to be defined somewhere or the page ships unstyled. Same spec,
rendered as CSS onto the FSE global-styles record:
python scripts/stylebook.py push reference/starter-tokens.json --core
python scripts/stylebook.py css reference/starter-tokens.json > stylebook.css # or paste
The starter system comes out around 9KB of CSS. That record is native to FSE themes and survives theme updates, which makes it the closest equivalent to a GreenLight global class.
reference/starter-tokens.json is a complete working system: colour, radius, focus-ring and
fluid type/spacing tokens; button, eyebrow, card, form and screen-reader classes; the
layout classes below; and a base layer in elements (box-sizing, reduced-motion-aware
smooth scroll, body full-height + overflow-x:clip, link colour and an external-link arrow
indicator on text links only, paragraph rhythm) plus .h1-.h6 heading look-alike classes
and a .hidden-accessible screen-reader-only class. Those are the global rules that cannot
be drawn in a design and belong site-wide, authored once here, not per block. Rename the gt- prefix per project if you like, just rename it
consistently.
Colours are role tokens, not colour names. primary / primary-dark, secondary /
secondary-dark, accent, surface / surface-alt, text / text-muted, focus. A
rebrand is then a value change; no generator, class or page mentions a hue. Keep the
-text variants (accent-text, secondary-text): they exist because the fill colour
fails AA as text, and a role name must not hide that. Two tints of a role are fine as
-light / -dark; at three or more switch to a numeric scale (secondary-100 …
secondary-900) or the names stop meaning anything. With --theme, primary can alias
straight onto the theme's own brand preset via map --apply.
Register the tokens with the theme, not beside it. Upstream prefers the theme's own
--wp--preset--* and --wp--custom--* variables to a parallel token set, because two
systems drift. push --theme does both: every variable carries a kind (color,
font-size, spacing, font-family, custom) and is written as a theme.json preset of
that kind on the user global-styles record, then the stylebook variable is rewritten to
alias it, --gt-primary: var(--wp--preset--color--gt-primary). Generators keep the gt-
names; the theme owns the values, the site editor shows them, and core blocks can pick
them from the preset pickers. A token that already exists in the theme under another name
gets an alias instead of a new preset; stylebook.py map --apply fills those in wherever
the values match.
python scripts/stylebook.py map reference/starter-tokens.json --apply # reuse theme presets
python scripts/stylebook.py push reference/starter-tokens.json --theme # presets + aliased stylebook
python scripts/stylebook.py remove reference/starter-tokens.json # retire the lot
The theme's own palette is replaceable too. The Stylebook's "Global Color Presets",
the editor pickers and GreenLight's own CSS (var(--wp--preset--color--brand)) read the
palette at the theme origin, which custom presets never touch. A colour token that
carries theme_slug ("theme_slug": ["brand"], or several slots) has its value written
into those slots on the user global-styles record by push --theme, the way GreenLight's
onboarding sets its brand colour. Slot slugs never change, so nothing in the theme
dereferences a missing variable, and unmapped slots keep the theme value. The starter
tokens bind primary → brand, surface → background, surface-alt → card-base,
text → textcolor, heading, card-text, text-muted → lightgrey. If the site already
overrides the theme palette (GreenLight's onboarding does), that list is the base and only
the bound slots change. push prints every slot it changes; remove leaves them, put the
old values back in Appearance → Editor → Styles if wanted.
Two things WordPress does on the way that will bite a hand-written alias: preset slugs and
settings.custom keys are kebab-cased before the variable is emitted, so gt-h1 is
--wp--preset--font-size--gt-h-1, and each of settings and styles on the record is
replaced whole on write. stylebook.py mirrors both; verify reports any alias whose
target the page never defines.
Layout classes hold every breakpoint: gt-grid-2 gt-grid-3 gt-grid-4 gt-grid-even
gt-grid-split gt-footer-grid gt-form-row wp-section wp-content-wrap.
Every prose class carries a max-width. gt-lead and gt-body-copy cap at 68-72ch.
Without it a class looks right inside a two-column split and runs 180 characters the first
time it is used full-width, the page reads as unstyled and the missing property is the last
thing anyone suspects.
Prune on push. A class you delete locally stays on the server, because the push replaces the array it sends rather than diffing it. Read the stored classes, keep anything outside your prefix, and drop prefixed classes you no longer define.
Read the theme's own tokens before you design any. The theme already emits a large set of custom properties, and building a parallel system alongside them guarantees drift: a container width that disagrees with the theme's, a radius token its own buttons ignore.
import re, urllib.request
html = urllib.request.urlopen(WP().url).read().decode()
print(sorted(set(re.findall(r'(\-\-wp\-\-[a-z0-9-]+)\s*:', html))))
Then consume what exists, define what the theme references but leaves undefined, and
invent only what is genuinely specific to this design. Read the install, not the docs.
A live GreenLight 2.1 emits 81 properties: 20 font sizes, 12 spacing steps, 11 shadows,
--wp--style--global--content-size and --wp--style--global--wide-size, and zero
border-radius or size customs, whichever version the documentation describes.
Gate the palette on contrast before you build on it. python scripts/check_contrast.py --bg "#fbf6ec" --fg body:#8c8172 accent:#e27b4b reports ratios and suggests passing
variants. Mid-tone brand colours routinely fail AA on light surfaces; fixing the token costs
one call, fixing it after the build costs a retrofit.
Images
Every raster image ships as WebP. No exceptions for PNG or JPEG. Image weight is
normally the largest single component of a page; WebP is 25-35% smaller than JPEG at the
same quality and dramatically smaller than PNG. wp_api.upload_media() converts anything
raster it is handed, so a stray PNG cannot reach the media library by any route, including
a one-off manual upload from a script. SVG passes through untouched, GIF passes through
because converting kills the animation, and keep_format=True exists for a genuine
third-party requirement.
python scripts/prep_images.py build # input/raw -> assets/, all WebP, q82
python scripts/prep_images.py build --max 1600 # cap the long edge
python scripts/prep_images.py upload # upload + write reference/media-map.json
python scripts/prep_images.py audit # non-WebP already in the library
Run build before uploading so the sizing and quality are deliberate rather than the
conversion defaults. Nothing is ever upscaled, a source smaller than its display slot
is re-encoded at its own size, because stretching it adds bytes and removes detail.
Each image is encoded both lossy and lossless and the smaller one wins. A fixed lossy quality is the obvious default and it is wrong for a whole class of images every site has: logos, screenshots, diagrams, flat illustrations. Lossy encoders spend bytes on the hard edges they cannot represent, so a 79kB PNG logo comes back as a 153kB WebP, a regression wearing the costume of an optimisation. Photographs go the other way and compress far better lossy. Trying both costs one extra encode and removes the guesswork; the build prints which mode won and warns if any output is still larger than its source.
Alt text is required at upload, not added later: upload refuses any image without an
alt in media-map.json (shape in reference/media-map-template.json) and tells you which. Set it on the attachment (alt_text), not
only in the block, so the media library stays correct.
Emit srcset/sizes on anything that spans a responsive slot, loading="lazy" everywhere
except the LCP image, and fetchpriority="high" on that one. Always width/height to
reserve space.
prep_images.py audit and verify.py --all both fail on a non-WebP raster image, so this
stays enforced after handover rather than only at build time.
The documented section shell
A full-bleed band is an FSE alignfull GreenShift Section (contentwrapper) wrapping
a Content area (nocolumncontent). section() and container() emit it.
columns() emits contentcolumns + contentarea for splits.
<section class="gsbp-xxxxxxx wp-section alignfull" data-type="section-component">
<div class="gsbp-yyyyyyy wp-content-wrap" data-type="content-area-component">…</div>
</section>
Section (inspector Layout + Spacing + Background), on the block itself: flex column,
justify/align as the design, row gap, vertical padding, side padding via
var(--wp--custom--spacing--side, min(3vw, 20px)), marginTop/marginBottom 0px,
background. No width. section() refuses width/maxWidth/minWidth; FSE
alignfull already spans. Inner wrap only: width: var(--wp--style--global--wide-size, 1200px),
max-width: 100%. That is what inserting Section in the plugin's editor produces, so
every band stays editable in the block inspector and a page renders without a stylebook push.
Nothing in the theme or the plugin styles .wp-section / .wp-content-wrap on the front
end (Greenlight 2.1 and gl-page-builder 3.3.7 both checked; the only occurrence is the prompt
text inside the editor bundle). Do not paste upstream's sample .wp-section{…} CSS into a
page; do not strip the block's own layout as a "theme duplicate" — an earlier version of
this skill did and produced full-width, un-centred sections. The same two rules also ship
as stylebook classes (reference/starter-tokens.json) for two narrower jobs: the core
backend, which cannot carry per-block layout, and retrofitting pages built by that earlier
version. Upstream writes --wp--spacing--side; the theme never defines it, so its fallback
always fired. Full mapping: reference/fse-and-greenshift.md.
Where a page's CSS lives
Three mechanisms, and picking the wrong one is the most common way to ship an unstyled page.
| Scope | Mechanism |
|---|---|
| site-wide tokens and shared classes | the stylebook (global_settings), or the FSE global-styles record on the core backend |
| one page's own classes | a stylemanager block: style_manager(seed, classes={'home-hero': '.home-hero{…}'}), emitting isVariation:"stylemanager" with dynamicGClasses in the converter's shape |
| a page's compiled block styles | _gspb_post_css, written by WP.push_page() from compile_css(), which folds the stylemanager's CSS in too |
What the PHP renderer behind CSSRender actually emits, probed on a live install: plain
styleAttributes properties (responsive arrays included), dynamicGClasses[].css and
dynamicGClasses[].selectors[].css. Nothing else. customCss on a stylemanager and
customCSS_Extra anywhere are compiled by the editor's JavaScript only, so on a template
target they never reach the page; style_manager() refuses custom_css there and
check_blocks.py flags both. A template part's class-less CSS belongs in the stylebook,
which is site-wide like the part itself. On a page target compile_css() includes them,
matching what an editor save would have written.
Never add CSSRender to a block you did not author. The theme's own blocks ship
styleAttributes next to already-compiled inlineCssStyles. Adding CSSRender re-emits the
raw values, which override the compiled rules. Doing this to a GreenLight header turns the
hidden mobile panel into a fixed full-height overlay across the whole site. The rule
"CSSRender on anything with styleAttributes" applies to your blocks only.
update_page() clears _gspb_post_css by default. That is correct on the CSSRender
path and destructive on the page path, where the field is the stylesheet. Use
WP.push_page() for pages: it writes content with clear_css=False and then stores the
compiled CSS, falling back to the page meta field if css_settings is blocked.
Generating pages
Use scripts/blocks.py, block(), image(), svg_icon(), section(), container(),
columns(), background_video(), grid(), button(), heading(), eyebrow(),
style_manager(), raw_html(), shortcode(). See examples/ for two complete
generators. Prefer these helpers over convert.js when the source is a design file:
the converter copies board widths into sections.
One finished HTML file in hand? Use upstream's converter instead. Hand-emitting from
Python suits many pages built from structured data. For a single design that already
exists as clean HTML, WPsoul's convert.js maps every element, files the <style> into a
stylemanager block, and deconvert.js brings it back for editing. scripts/convert_html.py
wraps it with this skill's delivery contract:
python scripts/convert_html.py input/home.html -o output/home.html --target page
python scripts/convert_html.py input/home.html --target page --publish "Home" --slug home
python scripts/convert_html.py input/promo.html -o output/promo.html --target template
It escapes -- in the block JSON, sets CSSRender per target, compiles the page CSS (or
ships the original stylesheet with --raw-css), runs check_blocks.py, and reports what
the converter changed: a rule is filed under the first class in its selector and the
compound before it is dropped (body.dark .title becomes .dark .title), and a rule with
no class at all lands in customCss, which never renders on a template target.
- Block ids:
gsbp-+ 7 chars, deterministic from a seed;localIdidentical; the id must appear in the HTMLclass. --cannot appear in an HTML comment, so CSS custom properties are escaped to--inside block JSON. Watch out forre.subreplacement strings containing\u, use a lambda.- Headings carry margins and alignment only; size, weight and colour come from element
styles. One
h1per page,h2per section,h3for cards, and never skip a level, a section ofh3cards needs anh2above it, visually hidden (gt-sr-only) if the design has no visible heading there. - Eyebrows are
divs, not headings. Content cards arearticle. classNamegoes in the block JSON and the HTMLclassattribute.blocks.pydoes both; hand-written markup that only sets one will not survive an editor round trip.type,name,placeholderandrequiredon a form control belong informAttributes, not in the main JSON and not indynamicAttributes.- Class and id prefixes are four characters minimum. This skill's own
gt-predates that rule; pick a real project prefix (ketup-,booz-) for anything page-specific. - No
:rootvariables and no styles onbodyor*in page CSS. Variables belong on a parent block's class. The stylebook is the exception and a deliberate one: it is the global layer, andglobal-settings.mdexpects:root-style declarations to be extracted into it rather than left in the page.
Raw HTML is a last resort, not a shortcut. raw_html() exists for scripts, JSON-LD,
stylesheets and shortcodes. It raises if you hand it content-shaped markup, because content
in a core/html block:
- cannot be edited in the block editor, which defeats the point of handing over a block site
- takes inline styles rather than stylebook classes, so it ignores tokens and every responsive breakpoint the layout classes carry
- is invisible to
verify.pyand the block checks, no heading-order, alt-text or contrast coverage - silently stops matching the rest of the site the first time a global class changes
A grid of cards is grid() wrapping one block(..., 'article') per card, each holding a
heading() and a block(..., 'p'). It is more lines than a slab of markup and it is the
deliverable the client actually bought. If some third-party embed genuinely has to stay raw,
pass raw_html(markup, reason='…') and say why in the reason.
Never send status on a content update. Send it when creating a page, and when
deliberately changing state, never on every push. A helper that always sends
status:"draft" unpublishes the live site the first time it runs against production, and a
REST write can flip a draft to published as a side effect. Read the statuses back after any
bulk update.
Push pages with WP.push_page(), not a raw content POST. It creates or updates over
/wp/v2/pages (create sends status:"draft" and template:"no-title"; update sends
{"content":…} only) and then writes the compiled CSS to _gspb_post_css. Do not clear
that field afterwards. The no-title template stops the theme printing the page title
as a second h1; confirm the slug in GET /wp/v2/templates. Template parts still use
update_page(..., clear_css=True) / CSSRender, because there the meta would shadow the
server-compiled rules.
Header and footer (FSE template parts)
GET|POST /wp/v2/template-parts/{theme}//{slug}, payload {"content"}. Do not assume the
slugs: WP.get_template_part(area='header') reads GET /wp/v2/template-parts, picks the
part whose area matches (a customised copy over the theme file), and works on any FSE
theme. WP.set_template_part(area='footer', content=…) writes the same way.
Which of the two treatments a header gets is decided by what is in it, not by the theme's
name: blocks.has_greenshift_blocks(raw).
A GreenLight header: edit surgically, never rewrite. GreenLight's header contains working
GreenLight navigation machinery, hamburger trigger, sliding mobile panel, menu-copy areas,
generated control ids (gs_menu_XXXX). Download the raw content and patch it: prepend a
topbar, swap the placeholder <li> items inside the menu <ul>, replace the demo CTA,
restyle the wrapper group. The mobile panel copies the desktop menu at runtime, leave it
alone.
Any other header (core navigation block, another theme) has no machinery to protect.
Rewrite it like the footer, keeping the theme's wp:navigation block by ref so menus stay
editable in Appearance → Editor.
The theme's hamburger ships without an accessible name. Add aria-label, aria-controls
and aria-expanded, plus a small delegated script that flips aria-expanded and the label
when it toggles. examples/generate_chrome.py shows the exact patches.
Footer: rewrite freely with GreenLight elements on gt-footer-grid. Wrap link columns in
<nav aria-label="…">.
Interactivity
GreenLight reads frontend scripts from the gspb_block_js option, not from post content.
The editor writes that option on save, so a block inserted over REST carries customJs
that never runs. Upstream gives three ways to deal with it, in preference order:
| How | Cost | |
|---|---|---|
| A | WP-CLI wp option update gspb_block_js |
needs shell access to the host |
| B | POST greenshift/v1/update-custom-js, WP.set_block_js() |
needs manage_options |
| C | put the script in a wp:html block at the end of the page |
none |
This skill uses C by default, which is a documented fallback rather than a workaround:
it needs no extra capability and survives hosts that block the endpoint. Strip customJs
and customJsEnabled from the block when you do, or the script is defined twice. On this
path you must replace {{PLUGIN_URL}} with the real plugin path, because raw wp:html
output is never processed by PHP. Options A and B keep the placeholder, which PHP resolves
at render time.
Whichever route, use event delegation on document so behaviour survives re-saves and
reordering, and test on the front end: the editor canvas never runs these scripts.
To show/hide, set el.style.display. Never use the hidden attribute: block CSS with
display:flex beats [hidden], so elements report themselves hidden while staying visible.
Verify with offsetParent === null or computed display, not with the property you just set.
Filter pattern that works: button chips with aria-pressed, a container with role=group
and a label, an aria-live region announcing the result count, and an empty-state message.
Card categories go in data-* attributes declared in the block JSON.
Client-side filtering suits a curated set. Only reach for posts + categories with a GreenLight query grid when the client needs to add items themselves, and WooCommerce only when they actually sell online. A custom post type is rarely the right first step.
Animation
Upstream's pointers are in instructions/scripts.md and validate-scripts.md: scripts
ride on a block as customJs, the front end reads them from the gspb_block_js option
(so REST-inserted scripts need option B or C above), and never hide an element with
CSS and reveal it with a script, because the editor canvas runs no scripts and the
element would vanish there. Set the hidden start state inside the script, right before
animating. Prefer CSS transitions for hover and small entrances; reach for a library for
scroll-driven and sequenced motion.
Two things upstream gets wrong for this theme, both verified on gl-page-builder 3.3.7:
- The plugin folder is
gl-page-builder, notgreenshift-animation-and-page-builder-blocks. Read it withWP.greenshift_plugin_url()
…(truncated)