WorshipKit Design Rules
A guide to the rule-based Design editor at /slides/designs/:id — for church admins building their own designs, for their AI assistants helping them, and for the WorshipKit team.
Public URL: https://worshipkit.com/design-rules Also served as raw Markdown at: https://worshipkit.com/design-rules.md
If you're an AI agent helping a WorshipKit user set up a Design, read this whole page first — every wire-format detail below matches what the engine actually consumes.
What a Design is
A Design is a saved recipe that turns a document (Word, Pages, or PowerPoint) into ProPresenter slides. It has three pieces:
1. Slide rules (segment_rules) — decide which paragraphs become slides, how they're chunked, and how references are extracted. 2. Text rules (style_rules) — decide how the runs on those slides are styled and routed to theme elements. 3. Options — post-process switches: add_blank_slides, split_long_text, treat_quotes_as_scripture, split_lists_as_points.
The rules run top-down through two phases. Slide rules run first (the Selector / Assembler in pp7gen), then post-process options apply, then Text rules run (the StylePass). First match wins within each rule's condition.
The pipeline (why order matters)
input paragraphs
↓
[ Slide rules ] ← segment_rules, top-down, first match wins
↓
[ apply_slide_options ] ← split_lists_as_points + split_long_text + add_blank_slides
↓
[ Text rules ] ← style_rules, top-down, first match wins per run
↓
rendered slides
- A slide rule that fires on a paragraph consumes it — later slide rules don't see the same paragraph a second time.
- Text rules run per-run on each slide. A rule that matches a bold run applies its style; the next text rule can still match a different run.
- Because Text rules run *after* the split into slides, a Text rule can't change which paragraphs become which slides. Use a Slide rule for that.
Rule anatomy
Every rule is a JSON object with the same top-level shape:
{
"id": "r-my-rule",
"name": "Human-readable name",
"enabled": true,
"when": { /* a Condition (leaf or group) */ },
"then": { /* an Action */ }
}
Conditions
A condition is a leaf (single check) or a group (AND / OR of children).
Leaf:
{ "t": "c", "kind": "bold", "negate": false }
Group (AND / OR of children):
{
"t": "g",
"op": "AND",
"children": [
{ "t": "c", "kind": "bold", "negate": false },
{ "t": "c", "kind": "highlight", "value": "yellow" }
]
}
negate: true inverts a leaf. Groups can nest.
Condition kinds (kind: values)
Paragraph-scoped — evaluated once per paragraph.
kind | Value type | Meaning |
|---|---|---|
is_blank | none | Paragraph is empty / whitespace only |
is_image | none | Paragraph is an inline image |
starts_with | text | Paragraph text begins with value |
starts_with_two_dashes | none | Paragraph starts with -- |
all_bold | none | Every meaningful run in the paragraph is bold |
all_italic | none | Every meaningful run in the paragraph is italic |
all_underline | none | Every meaningful run in the paragraph is underlined |
all_text_color | color (see text_color below) | Every meaningful run has this text color |
starts_with_bold_underline | none | Paragraph LEADS with a bold+underlined phrase |
underlined_after_heading_prefix | none | After stripping a #<digit>— prefix, the rest is underlined |
single_line_slide | none | Slide body is a single line (no \n breaks) |
is_scripture_slide | none | Slide has a Bible reference or Scripture group label |
slide_type_is | text | Slide's type equals value (e.g. point, scripture) |
text_matches | text (regex) | Paragraph text matches the regex |
is_footnote_marker | none | Bible-gateway footnote marker ([a], etc.) |
followed_by_colon | none | Next character after the paragraph is : |
matches_inline_ref_body | none | Matches Reference: body on one line |
splits_into_ref_and_body | none | Reference is at the start or end of chunk |
contains_bible_reference | none | Any Bible reference anywhere inside |
bible_reference | none | Paragraph *is* a Bible reference |
next_paragraph_bible_reference | none | The next non-blank paragraph is a bare Bible reference |
previous_paragraph_bible_reference | none | The previous non-blank paragraph is a bare Bible reference |
verse_label | none | Paragraph is a [Verse 3]-style label |
person_name | none | Text approximates a person's name |
all_caps | none | Paragraph text is ALL CAPS |
"Meaningful runs" — all_bold / all_italic / all_underline ignore runs that are pure whitespace or punctuation, and treat a word-processor highlight as equivalent emphasis. That way a Word document that splits Advantage of a Helper into [Advantage][ ][of][ ][a][ ][Helper] runs still matches all_underline even though the space runs aren't underlined.
Neighbour Bible-reference guards. Use next_paragraph_bible_reference (with negate: true) on a paragraph selector when the next paragraph is a bare reference that will pull this paragraph's text in as its scripture body (via body_source: previous_body — see the extractor table below). Without the guard, the same paragraph emits twice: once as its own slide, and again as the body of the following scripture slide. previous_paragraph_bible_reference is the symmetric guard for the reverse layout.
Run-scoped — evaluated per run. A paragraph matches if *any* of its runs match, unless the rule engine says otherwise.
kind | Value type | Meaning |
|---|---|---|
highlight | color (yellow, green, blue, pink, purple) | Run has this word-processor highlight |
text_color | color (red, blue, green, gold, orange, purple, white) | Run text color |
text_color_hex | hex (#00a2ff) | Run's raw text-color hex (case-insensitive) — use when the semantic palette name is too coarse (e.g. two source shades both snap to blue) |
comment_highlight | none | Run carries a word-processor comment (Pages / Word inline comments) — surfaced by pages_input_strategy as an emphasis signal when no other run-level style discriminator is available |
bold | none | Run is bold |
italic | none | Run is italic |
underline | none | Run is underlined |
strike | none | Run has strikethrough |
font_size | choice (larger, smaller, same) | Compare against the paragraph's average |
regex | text (regex) | Run text matches the regex |
line_position | choice (first, middle, last) | Where the run sits on its line |
split_run_side | choice (first, last) | The run was produced by a preceding split_run_on_regex action, and this is the side (LHS / RHS) it came from. Negated with no value ({negate: true}) matches runs that were NOT produced by a split — useful for a fallback style rule alongside per-side rules |
word_count | number | Run word count ≥ N |
wrapped | { open, close } | Text is wrapped in these markers |
neighbor_wrapped | { direction, open, close } | The paragraph before / after is wrapped |
Color conditions store a stable *name* (not the hex) so a theme swap doesn't invalidate the rule.
Palette scope. The text_color palette is deliberately narrow. Explicit black is not included — most word processors treat black as the default body text, so a text_color: "black" condition would fire on every plain paragraph. White *is* included: authors only set white text when they've paired it with a dark paragraph fill (a distinctive block layout — sermon-theme cards, callouts), so it's a high-signal match. Everything else in the grey axis (mid-grey, off-white below the near-white cutoff) is treated as "no explicit color."
Slide rule actions (segment_rules[].then)
Selectors — decide what becomes a slide
action | Fields | Effect |
|---|---|---|
split_on | keep_boundary (bool) | This paragraph closes the current chunk |
flush_and_emit | — | Flush the current chunk and emit this paragraph as its own slide |
span_between | open, close, outside ("context" or "keep") | Text between markers is the slide |
paragraph_matching | strip_prefix, strip_suffix | The paragraph itself is a slide (strip the markers) |
Assembly — reshape an already-selected candidate
action | Fields | Effect | ||
|---|---|---|---|---|
drop_candidate | — | Discard the candidate (e.g. footnote markers) | ||
set_group_label | label | Tag the slide with a group label | ||
set_theme_group | group | Force the slide to render in this named theme group | ||
set_slide_type | type | Set slide["type"] (e.g. point, sermon_theme, numbered_point) so downstream style rules can match slide_type_is | ||
join_paragraphs_with | separator | Join multiple paragraphs into one slide with this separator | ||
mark_author_line | flags | Recognise an author byline in a quote | ||
add_actions | actions (array — see below) | Attach one or more ProPresenter cue-level actions (audience look, timer, macro) to the emitted slide. Fires ONCE per passage even when split_long_text fans a scripture chunk across multiple cues | ||
split_run_on_regex | pattern (regex), keep_boundary (drop \ | left \ | right) | Split each matching segment into two runs at the first regex match, tagging the LHS/RHS with split_run_side for downstream style_rules to key on. keep_boundary controls whether the matched separator itself is dropped, kept on the left run, or kept on the right run |
Cue-level actions (then.actions)
Any segment rule with an assembly action may also carry an actions: array — either on the standalone add_actions shape or piggybacked on set_slide_type, set_group_label, set_theme_group, etc. Each entry attaches one ProPresenter action to the generated slide's cue.
{
"when": { "t": "c", "kind": "slide_type_is", "value": "scripture" },
"then": {
"action": "add_actions",
"actions": [
{ "kind": "audience_look", "name": "Scripture" }
]
}
}
Combined with a slide-type rule in a single pass:
{
"when": { "t": "c", "kind": "splits_into_ref_and_body" },
"then": {
"reference_source": "positional_split",
"body_source": "body_slice",
"group_label": "Scripture",
"actions": [ { "kind": "audience_look", "name": "Scripture" } ]
}
}
Two rules attaching the same (kind, name) de-duplicate — the cue gets one action, not two. Style rules deliberately do NOT support then.actions; actions belong at the cue level, not per-run.
kind | Fields | ProPresenter action | Status | ||||
|---|---|---|---|---|---|---|---|
audience_look | name | Action::AudienceLookType | Emitted | ||||
timer | name, timer_action (start \ | stop \ | reset \ | reset_and_start \ | stop_and_reset), duration_seconds | Action::TimerType | Emitted |
macro | name | Action::MacroType | Emitted | ||||
prop | name, prop_action (trigger \ | clear) | Action::PropType | Emitted |
By-name resolution. Audience-look / timer / macro name values are looked up against the church's ProPresenter workspace at export time. When ProPresenter can't find a match, pp7gen logs a warning and skips the action — no made-up UUIDs, no crashed export.
Workspace-sourced pickers in the editor. The DesignEditor's action row renders a <select> populated from the org's imported ProPresenter workspace (GET /api/v2/slides/workspace/audience_looks, .../timers, .../macros) — no freeform typing. Picking an option stores both name (what pp7gen resolves against today) and uuid (stable identity for a workspace rename) on the entry. If the workspace hasn't been imported yet, the picker disables and surfaces an "Import your ProPresenter workspace" hint so the designer knows where to go.
set_slide_type values are free-text but by convention the generator + themes recognise point, sermon_theme, scripture, numbered_point, song, blank, and any lower-snake-case identifier a design defines. Pair with a slide_type_is style rule to apply per-type styling.
Reference extraction — pull a citation out of the chunk
Reference extraction rules have no action key — the presence of reference_source is what tells the engine to dispatch to the ReferenceExtractor.
"then": {
"reference_source": "positional_split",
"body_source": "body_slice",
"cleanups": ["strip_boundary_separators"],
"group_label": "Scripture"
}
reference_source — where the reference text comes from:
| Value | Meaning |
|---|---|
inline_capture | Match a Ref: body shape and capture the ref side |
positional_split | Whichever end of the chunk looks like a reference |
whole_selection | The whole chunk is the reference |
paragraph_minus_dashes | Paragraph with leading -- stripped |
whole_paragraph | The whole matched paragraph |
match_to_eol | The reference-shaped run, to end of line |
body_source — where the slide's body text comes from:
| Value | Meaning |
|---|---|
rest_of_inside | Everything inside the marker after the ref |
body_slice | The non-reference slice of the chunk |
outside_before / outside_after | Text outside the marker, before / after |
outside_then_preceding_block | Walk backwards across paragraphs to find a body |
next_body / previous_body | The next / previous paragraph |
next_then_previous_body | Try next first, else previous |
remaining_paragraphs | All remaining paragraphs in the chunk |
text_before_match | Text preceding the matched reference |
cleanups — post-processing on the extracted text. String forms run against reference-body extractions; hash forms ({"kind": "...", ...args}) also run for paragraph_matching rules with no reference_source, so a selector rule can trim segments before the slide is emitted.
| Value | Meaning |
|---|---|
strip_boundary_separators | Trim commas / dashes at the chunk boundary |
strip_leading_colon | Trim a leading : |
strip_leading_ws_open_quote | Trim leading whitespace + open quote |
strip_verse_number_prefix | Trim leading 12-style verse numbers |
strip_trailing_close_quote | Trim a trailing close quote |
flatten_to_one_segment | Collapse runs into a single segment |
{ "kind": "trim_segments_not_matching_color", "color"?, "color_hex"? } | Drop every segment whose text_color (or text_color_hex) doesn't match the target. Empty target → no-op. |
group_label (optional) — same effect as set_group_label; a quick way to say "this is a Scripture slide."
Text rule actions (style_rules[].then)
action | Fields | Effect |
|---|---|---|
route | element (theme element name) | Route the matching run into that theme element |
style | color, weight, size, text_case, tracking, font_family, alignment, vertical_alignment, bold, italic, underline | Restyle the matching run (see below) |
split | — | Break the run onto its own line |
hide | — | Drop the run from the rendered slide |
strip_regex | pattern (regex string) | Remove the first regex match from the run's text — modifiers stay, only the text shrinks |
Style-attribute notes:
size— point size against a 1920×1080 canvas. The preview scales it; export uses it verbatim.font_family— PostScript family name (free-text; the editor offers a datalist of common families).alignment—left/center/right/justified. Lands on every matching run's style hash.vertical_alignment—top/middle/bottom.bold/italic/underline— booleans (not conditions). Settrueto force the modifier on,falseto strip it. Useful for normalising whole-paragraph emphasis (e.g.italic: falseto strip block-quote italics that Word applies to the first paragraph of a cited scripture passage).
Element-intent promotion. When a matching run is the *first* segment on the slide AND its style hash declares alignment or vertical_alignment, the generator promotes alignment, vertical_alignment, font_size, and font_family to the whole slide element (the theme's text box). This is the reliable signal that the rule intends whole-element styling (a slide-type body rule like "Point slides = Source Sans 3 centered, 114pt") rather than a per-run emphasis. A rule that only sets color / weight / font_family leaves the element bounds + font alone, so per-run emphasis (like a Black Diamond Emphasis accent on one word inside a scripture body) doesn't accidentally reshape the whole text box.
Nested then.style shape
Two wire shapes are accepted:
- Flat (default from the editor): ``
json "then": { "action": "style", "color": "#F7F2E8", "font_family": "Source Sans 3" }`` - Nested: ``
json "then": { "style": { "on": true, "font_family": "Lora", "italic": true, "highlight": null } }``
The nested shape is required when you want to set highlight: null (clear a word-processor highlight so a downstream emphasis carries the accent instead of the yellow rectangle). The flat shape's serializer drops nil values, so highlight: null survives only in the nested form.
Options (post-process)
add_blank_slides— insert a blank slide between distinct content chunks. Skipped between the continuation pieces of a split passage.split_long_text— break a long body across multiple slides at sentence boundaries when possible; the same reference rides along on every piece.treat_quotes_as_scripture— peel a Christian author name (C.S. Lewis, D.A. Carson, and similar) off the trailing edge of a quote slide intoslide["reference"], so quote attribution styles the same as a Bible citation.split_lists_as_points— when a slide body contains multiple non-blank lines (either soft breaks or paragraph-broken lines that the selector joined into one chunk), split into one slide per line and mark eachslide_type: "point". Pair with aslide_type_is: "point"style rule to apply point styling automatically.
Preceding / trailing boilerplate slides
Every design has two optional "boilerplate" slots — decks the user authors once and gets attached to every conversion that uses that design:
- Preceding slides — prepended to the converter output.
- Trailing slides — appended to the converter output.
Each slot has:
- an
enabledtoggle (default off); - a lazily-created
SlideDocumentthat opens in the standard/slides/editor/:idwhen the user clicks Edit in the design rail.
Boilerplate slides are ordinary ProPresenter slides — every action, element, background image, transition, and cue-note the user set on them is preserved verbatim in the merged output. No blank slide is inserted between the boilerplate and the converter's cues; add one to the boilerplate deck explicitly if you want a spacer.
The join happens on every import path that resolves a design — Quick Import, Draft Review + Generate, AI Import, and legacy /slides/converter. Presentation import (third-party .pptx / .key / .pro / .proBundle) does not attach boilerplate today.
Common recipes
Scripture with the reference at the end
{
"when": { "t": "c", "kind": "splits_into_ref_and_body" },
"then": {
"reference_source": "positional_split",
"body_source": "body_slice",
"group_label": "Scripture"
}
}
Sermon points wrapped in [brackets]
{
"when": { "t": "g", "op": "AND", "children": [] },
"then": { "action": "span_between", "open": "[", "close": "]", "outside": "context" }
}
Route yellow-highlighted runs into the Emphasis element
{
"when": { "t": "c", "kind": "highlight", "value": "yellow" },
"then": { "action": "route", "element": "Emphasis" }
}
Hide ALL-CAPS section headers
{
"when": { "t": "c", "kind": "all_caps" },
"then": { "action": "hide" }
}
Bold + underlined runs become green Courier at 110pt
{
"when": {
"t": "g", "op": "AND",
"children": [
{ "t": "c", "kind": "bold" },
{ "t": "c", "kind": "underline" }
]
},
"then": {
"action": "style",
"color": "#22c55e",
"weight": "bold",
"size": 110,
"font_family": "Courier New"
}
}
Point slides render in 114pt Source Sans 3 centered on parchment
{
"when": { "t": "c", "kind": "slide_type_is", "value": "point" },
"then": {
"action": "style",
"color": "#F7F2E8",
"font_family": "Source Sans 3",
"weight": "bold",
"size": 114,
"alignment": "center",
"vertical_alignment": "middle"
}
}
The alignment + vertical_alignment are the intent signal that promotes font_family + size to the whole slide element.
Whole-paragraph bold+underline → typed as a Point slide
{
"when": {
"t": "g", "op": "AND",
"children": [
{ "t": "c", "kind": "all_bold" },
{ "t": "c", "kind": "all_underline" }
]
},
"then": { "action": "set_slide_type", "type": "point" }
}
Single-line slides starting with #1— → strip the marker + type as a numbered_point
Two rules — one segment rule to type, one style rule to strip:
{
"when": {
"t": "g", "op": "AND",
"children": [
{ "t": "c", "kind": "single_line_slide" },
{ "t": "c", "kind": "regex", "value": "^#\\d+[-‐‑‒–—―]" },
{ "t": "c", "kind": "is_scripture_slide", "negate": true }
]
},
"then": { "action": "set_slide_type", "type": "numbered_point" }
}
{
"when": {
"t": "g", "op": "AND",
"children": [
{ "t": "c", "kind": "single_line_slide" },
{ "t": "c", "kind": "line_position", "value": "first" },
{ "t": "c", "kind": "regex", "value": "^#\\d+[-‐‑‒–—―]" }
]
},
"then": { "action": "strip_regex", "pattern": "^#\\d+[-‐‑‒–—―]\\s*" }
}
Highlighted words on point / sermon_theme slides → Lora italic, clear the highlight
{
"when": {
"t": "g", "op": "AND",
"children": [
{ "t": "c", "kind": "highlight" },
{ "t": "g", "op": "OR", "children": [
{ "t": "c", "kind": "slide_type_is", "value": "point" },
{ "t": "c", "kind": "slide_type_is", "value": "sermon_theme" }
] }
]
},
"then": { "style": { "on": true, "font_family": "Lora", "italic": true, "highlight": null } }
}
Iterating in the editor
The Live preview pane on the right runs your rules against either a uploaded document (.docx / .pages / .pptx) or a two-slide sample deck (a random C.S. Lewis quote + John 3:16-19 NKJV) when no upload is present.
- Above each slide the chip legend shows the current Theme › Group › Choice —
routed by rulemeans aset_theme_grouprule picked the group;auto (scripture|point|blank)means slide-type inference did. - Every theme element gets a dashed light-gray outline with its name; runs routed by a rule get the brighter cyan outline instead.
- The Source paragraphs pane under each slide shows exactly which input paragraphs pp7gen mapped to that slide (via provenance from the engine).
For AI agents
If a WorshipKit user asks you to help set up a design, the reliable workflow is:
1. Ask what the source document looks like (Bible references at end of paragraph? Bracketed callouts? Asterisk-marked points?). 2. Pick a starting stock design (Default, Brackets, Asterisks) or Custom slide rule from the + New rule menu. 3. Compose conditions using only the kind values above — no other names are recognised. 4. Recommend add_blank_slides: true and split_long_text: true for sermons; both are safe defaults. 5. When guessing hex codes for style.color, prefer stable Tailwind swatches (e.g. #facc15 for gold, #96d35f for a highlight green) so a theme swap still reads sensibly.
Where this lives
- Source of truth:
docs/design_rules.mdin the WorshipKit web repo. - Served publicly at:
/design-rules(HTML) and/design-rules.md(raw markdown). - Discovered by AI agents via:
/llms.txtat the site root. - Kept up to date by: the "update the design-rules docs" contributor rule in
CLAUDE.md.
If you add a new condition kind, action, cleanup, or option to either the pp7gen engine or the frontend editor, update this file in the same commit.