# 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:

```json
{
  "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:
```json
{ "t": "c", "kind": "bold", "negate": false }
```

Group (AND / OR of children):
```json
{
  "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                        |
| `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 |

`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.

```json
"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). Set
  `true` to force the modifier on, `false` to strip it. Useful for
  normalising whole-paragraph emphasis (e.g. `italic: false` to 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 into `slide["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 each `slide_type: "point"`. Pair with a `slide_type_is:
  "point"` style rule to apply point styling automatically.

---

## Common recipes

**Scripture with the reference at the end**
```json
{
  "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]`**
```json
{
  "when": { "t": "g", "op": "AND", "children": [] },
  "then": { "action": "span_between", "open": "[", "close": "]", "outside": "context" }
}
```

**Route yellow-highlighted runs into the Emphasis element**
```json
{
  "when": { "t": "c", "kind": "highlight", "value": "yellow" },
  "then": { "action": "route", "element": "Emphasis" }
}
```

**Hide ALL-CAPS section headers**
```json
{
  "when": { "t": "c", "kind": "all_caps" },
  "then": { "action": "hide" }
}
```

**Bold + underlined runs become green Courier at 110pt**
```json
{
  "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**
```json
{
  "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**
```json
{
  "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:
```json
{
  "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" }
}
```
```json
{
  "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**
```json
{
  "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 rule` means a `set_theme_group` rule
  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.md` in the WorshipKit web
  repo.
- **Served publicly at:** `/design-rules` (HTML) and
  `/design-rules.md` (raw markdown).
- **Discovered by AI agents via:** `/llms.txt` at 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.
