@fluid-app/fluid-cli-theme-dev 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ # Global settings — typography, color, spacing, dark mode
2
+
3
+ > Part of the `themes-review` skill. See [`../SKILL.md`](../SKILL.md) for the review workflow, severity ladder, and validator rules.
4
+
5
+ ## Contents
6
+
7
+ - The pattern
8
+ - What `config/settings_schema.json` looks like
9
+ - What `layouts/theme.liquid` does with them
10
+ - What belongs at the global level
11
+ - What does NOT belong at the global level
12
+ - Findings to surface
13
+ - Dark-mode patterns — two acceptable shapes
14
+ - Quick audit — globals health
15
+
16
+
17
+ The most-overlooked layer of a theme is **`config/settings_schema.json` + `layouts/theme.liquid`**. Globals are how a theme stays coherent: change one font in one place and every section follows. Reviewers should check both ends of this pipeline.
18
+
19
+ ### The pattern
20
+
21
+ 1. **`config/settings_schema.json`** declares theme-wide settings, grouped by `name` (typography, spacing, color_schema, shadows, border_radius, cards, etc.).
22
+ 2. **`layouts/theme.liquid`** reads `{{ settings.* }}` and emits **CSS custom properties on `:root`** inside a `<style>` block.
23
+ 3. **Sections and components** consume those custom properties via `var(--font-body)`, `var(--color-primary)`, etc. — never re-reading the underlying `settings.*` value.
24
+
25
+ ### What `config/settings_schema.json` looks like
26
+
27
+ ```json
28
+ [
29
+ {
30
+ "name": "theme_info",
31
+ "theme_name": "My Theme",
32
+ "theme_version": "1.0.0",
33
+ "theme_author": "..."
34
+ },
35
+ {
36
+ "name": "typography",
37
+ "settings": [
38
+ { "type": "header", "content": "Body" },
39
+ {
40
+ "type": "font_picker",
41
+ "id": "font_family_body",
42
+ "label": "Font family",
43
+ "default": "Inter"
44
+ },
45
+ {
46
+ "type": "range",
47
+ "id": "font_weight_body",
48
+ "label": "Weight",
49
+ "min": 100,
50
+ "max": 900,
51
+ "step": 100,
52
+ "default": 400
53
+ },
54
+ { "type": "header", "content": "Headings" },
55
+ {
56
+ "type": "font_picker",
57
+ "id": "font_family_heading",
58
+ "label": "Font family",
59
+ "default": "Inter"
60
+ },
61
+ {
62
+ "type": "range",
63
+ "id": "font_size_h1",
64
+ "label": "H1 size",
65
+ "min": 24,
66
+ "max": 96,
67
+ "step": 1,
68
+ "default": 48,
69
+ "unit": "px"
70
+ }
71
+ ]
72
+ },
73
+ {
74
+ "name": "color_schema",
75
+ "settings": [
76
+ {
77
+ "type": "color",
78
+ "id": "color_primary",
79
+ "label": "Primary",
80
+ "default": "#0a0a0a"
81
+ },
82
+ {
83
+ "type": "color",
84
+ "id": "color_secondary",
85
+ "label": "Secondary",
86
+ "default": "#ffffff"
87
+ },
88
+ {
89
+ "type": "color",
90
+ "id": "color_text",
91
+ "label": "Body text",
92
+ "default": "#111111"
93
+ }
94
+ ]
95
+ },
96
+ {
97
+ "name": "appearance",
98
+ "settings": [
99
+ {
100
+ "type": "checkbox",
101
+ "id": "enable_dark_mode",
102
+ "label": "Enable dark mode toggle",
103
+ "default": false
104
+ }
105
+ ]
106
+ }
107
+ ]
108
+ ```
109
+
110
+ Note: the **outer array is grouped**, not flat. Each object with a `name:` becomes a settings group in the editor UI. The `theme_info` group is metadata and contains no `settings:` array.
111
+
112
+ ### What `layouts/theme.liquid` does with them
113
+
114
+ ```liquid
115
+ <head>
116
+ ...
117
+ {{ settings.font_family_body | font_face: font_display: 'swap' }}
118
+ {{ settings.font_family_heading | font_face: font_display: 'swap' }}
119
+
120
+ <style>
121
+ :root {
122
+ /* Typography */
123
+ --font-body: {{ settings.font_family_body | font_family | default: "Inter, system-ui, sans-serif" }};
124
+ --font-heading: {{ settings.font_family_heading | font_family | default: "Inter, system-ui, sans-serif" }};
125
+ --font-weight-body: {{ settings.font_weight_body | default: 400 }};
126
+ --font-size-h1: {{ settings.font_size_h1 | default: 48 | append: 'px' }};
127
+
128
+ /* Color */
129
+ --color-primary: {{ settings.color_primary | default: '#0a0a0a' }};
130
+ --color-secondary: {{ settings.color_secondary | default: '#ffffff' }};
131
+ --color-text: {{ settings.color_text | default: '#111111' }};
132
+ }
133
+
134
+ {%- if settings.enable_dark_mode -%}
135
+ @media (prefers-color-scheme: dark) {
136
+ :root {
137
+ --color-primary: {{ settings.color_primary_dark | default: '#ffffff' }};
138
+ --color-secondary: {{ settings.color_secondary_dark | default: '#0a0a0a' }};
139
+ --color-text: {{ settings.color_text_dark | default: '#fafafa' }};
140
+ }
141
+ }
142
+ {%- endif -%}
143
+ </style>
144
+ </head>
145
+ ```
146
+
147
+ And sections then look like:
148
+
149
+ ```css
150
+ .featured-products h2 {
151
+ font-family: var(--font-heading);
152
+ font-size: var(--font-size-h1);
153
+ color: var(--color-text);
154
+ }
155
+ ```
156
+
157
+ ### What belongs at the global level
158
+
159
+ Anything that should stay consistent across the whole theme:
160
+
161
+ | Group | Settings typically here |
162
+ | --------------- | ------------------------------------------------------------------------------------------------------------ |
163
+ | `typography` | Body + heading font families, font weights, font size scale (`xs`–`9xl`), heading sizes (h1–h6), line height |
164
+ | `color_schema` | Brand palette (primary, secondary, accent, text, surface, border), plus dark-mode variants |
165
+ | `spacing` | Spacing scale, section padding scale |
166
+ | `layout` | Max container width, gutter, grid breakpoints |
167
+ | `border_radius` | Radius scale (sm, md, lg, full) |
168
+ | `shadows` | Shadow scale (sm, md, lg) |
169
+ | `cards` | Card-wide defaults (radius, shadow, padding) that per-card-type groups inherit |
170
+ | `appearance` | Dark mode toggle, motion-reduce, animation preferences |
171
+ | `theme_info` | Theme name, version, author, docs URL — **metadata only, no `settings:`** |
172
+
173
+ ### What does NOT belong at the global level
174
+
175
+ - Per-section labels and copy (those are section-level)
176
+ - Per-page hero images, banners, CTAs (section/block-level)
177
+ - One-off colors used by exactly one section (section-level)
178
+ - Anything that varies per page or per resource
179
+
180
+ The test: _would changing this break the visual coherence of the rest of the theme?_ If yes, it's a global. If no, it's a section setting.
181
+
182
+ ### Findings to surface
183
+
184
+ | You see | Severity | Fix |
185
+ | ------------------------------------------------------------------------------------------------------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
186
+ | Section adds its own `font_family` / `font_size` / `color_*` setting when the global already exists | `should` | Use `var(--font-body)`, `var(--color-primary)`, etc. Per-section overrides should be the _exception_, configured via section setting `color_override` that defaults to `unset`. |
187
+ | Theme has 0 global typography settings but every section hardcodes fonts in inline styles | `blocker` | Add a `typography` group to `config/settings_schema.json` and a `:root` `<style>` block in `layouts/theme.liquid`. |
188
+ | Theme has 0 global color settings but uses hardcoded hex everywhere | `blocker` | Add a `color_schema` group. |
189
+ | `layouts/theme.liquid` reads `settings.*` but emits no `<style>` / CSS variables | `should` | Wire the settings into `--var` declarations on `:root`. Without this layer, sections have no way to consume globals. |
190
+ | `config/settings_schema.json` is a **flat array of settings** (no `name:` groups) | `should` | The engine accepts it, but the editor UI flattens to one giant panel. Group by `name:` (typography, color, spacing, …) for usability. |
191
+ | `theme_info` group missing | `nit` | Adds version + author metadata visible to admins. |
192
+ | Two `font_family_body` and `font_family_heading` settings exist but every section uses a hardcoded `font-family: 'Inter'` | `blocker` | Rewire sections to `var(--font-heading)` / `var(--font-body)`. Globals that nothing consumes are dead weight. |
193
+ | Dark-mode toggle exists in schema but no `@media (prefers-color-scheme: dark)` / `[data-theme="dark"]` block in `theme.liquid` | `blocker` | Setting is a lie — wire it. |
194
+
195
+ ### Dark-mode patterns — two acceptable shapes
196
+
197
+ 1. **System-driven** (`@media (prefers-color-scheme: dark)`): respects OS setting, no user toggle. Lighter touch, no JS.
198
+ 2. **Toggle-driven** (`[data-theme="dark"]` on `<html>`): explicit toggle, persisted in `localStorage`, falls back to system. Heavier but lets users override.
199
+
200
+ Reviewer's rule: if the theme has a _toggle button_ anywhere, it MUST use the toggle-driven pattern. If it has none and only the schema checkbox, system-driven is fine.
201
+
202
+ ### Quick audit — globals health
203
+
204
+ ```bash
205
+ # 1. Are global groups defined?
206
+ jq -r '.[].name' config/settings_schema.json
207
+
208
+ # 2. Does the layout consume them?
209
+ grep -E 'settings\.' layouts/theme.liquid | head
210
+
211
+ # 3. Does the layout emit CSS variables?
212
+ grep -E '^\s*--[a-z-]+:' layouts/theme.liquid | head
213
+
214
+ # 4. Do sections actually USE the variables (vs. hardcoded)?
215
+ grep -rE 'font-family:\s*[A-Za-z]' --include='*.liquid' --include='*.css' . 2>/dev/null # hardcoded fonts
216
+ grep -rE 'color:\s*#[0-9a-fA-F]' --include='*.liquid' --include='*.css' . 2>/dev/null # hardcoded colors
217
+ ```
218
+
219
+ If steps 1–3 are populated and step 4 finds lots of hardcoded fonts/colors, the globals exist but nothing uses them — file a `should` to rewire.
220
+
221
+ ---
@@ -0,0 +1,176 @@
1
+ # Liquid correctness — the checks
2
+
3
+ > Part of the `themes-review` skill. See [`../SKILL.md`](../SKILL.md) for the review workflow, severity ladder, and validator rules.
4
+
5
+ ## Contents
6
+
7
+ - 0. Unavailable / out-of-scope variables
8
+ - 1. Nil-safety and defaults
9
+ - 2. Whitespace control
10
+ - 3. Unclosed tags / mismatched conditionals
11
+ - 4. Filter ordering
12
+ - 5. `forloop.first` / `forloop.last` instead of index branching
13
+ - 6. `assign` inside a loop for a constant value
14
+
15
+
16
+ Mechanical. Cite line numbers.
17
+
18
+ ### 0. Unavailable / out-of-scope variables
19
+
20
+ Liquid never raises on `{{ undefined_var }}` — it renders empty. The same is true for `{{ available.but.missing.field }}`. Together this means _typos and out-of-context drops are invisible at runtime._ The reviewer catches them.
21
+
22
+ The variables a template can read fall into three buckets:
23
+
24
+ | Bucket | Available in… | Examples |
25
+ | ---------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
26
+ | **Always available globals** | every template | `request`, `cart`, `customer`, `shop`, `settings` (global), `linklists` |
27
+ | **Resource context** | the matching page template, plus any section it renders | `product` (only on `product/{variant}/index.liquid` and sections rendered from it); `collection`, `category`, `post`, etc. |
28
+ | **Section / block scope** | inside that section only | `section`, `section.settings`, `section.blocks`, `block`, `block.settings` |
29
+ | **Component args** | inside a component only when explicitly passed | only the keys passed to `{% render 'name', key: value %}` |
30
+
31
+ Things that go wrong:
32
+
33
+ | You see | Severity | Why |
34
+ | -------------------------------------------------------------------------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
35
+ | `{{ product.title }}` inside a section that's rendered on the homepage (`home_page/default/index.liquid`) without an explicit `product:` arg | `blocker` | `product` is only in scope on product page templates. On `home_page` it's `nil`, silently rendering empty. |
36
+ | `{{ collection.products }}` inside `sections/main_navbar/index.liquid` | `blocker` | `collection` is not in scope in a navbar section. Pass the resource explicitly via a block setting (`collection` type) and read `block.settings.collection.products`. |
37
+ | `{{ cart.item_count }}` in a component without `cart` passed as a `render` arg | `should` | `cart` is global, so this often works, but if the component is intended to be self-contained, accept `cart:` explicitly. |
38
+ | `{{ section.settings.thing }}` where the schema does not declare `thing` | `blocker` | Typo or stale reference. Validator does not catch this — it only checks the schema, not the template. Grep the schema for the `id` and fix. |
39
+ | `{{ block.settings.thing }}` where the block's `settings:` array does not declare `thing` | `blocker` | Same as above for blocks. |
40
+ | `{{ block.settings.x }}` inside a section that has `blocks` declared but NOT inside a `{% for block in section.blocks %}` loop | `blocker` | `block` is only in scope inside the iteration; outside it's `nil`. |
41
+ | Inside a component, reading `section.settings.*` (component receives no `section` arg) | `blocker` | Components are partials. They only see what's passed via `{% render %}`. The caller must pass `section: section` (rare) or the specific values needed. |
42
+ | Inside a component, reading `block.settings.*` without `block:` passed as an arg | `blocker` | Same as above — components are not block-aware unless told. |
43
+ | `{{ foo }}` where `foo` was `{% assign foo = ... %}`-ed inside an `{% if %}` branch but is read after the `{% endif %}` with no fallback | `should` | Liquid `assign` has file-level scope, but readers downstream may see `nil` if the branch didn't run. Either move the assign above the `if`, or add ` | default: ...` at the read site. |
44
+ | `{{ for_loop_var }}` after a `{% endfor %}` (using the loop variable outside the loop) | `blocker` | Loop variables don't escape `{% endfor %}`. The reference renders empty. |
45
+ | `{{ forloop.first }}` outside a `{% for %}` | `blocker` | Only valid inside a loop. |
46
+
47
+ **Audit each file before approving:**
48
+
49
+ ```bash
50
+ # Find references to settings keys that aren't declared in the same file's schema
51
+ awk '
52
+ /{% schema %}/,/{% endschema %}/ { schema = schema "\n" $0; next }
53
+ /(section|block)\.settings\.[a-zA-Z_][a-zA-Z0-9_]*/ {
54
+ for (i = 1; i <= NF; i++) {
55
+ if ($i ~ /(section|block)\.settings\.[a-zA-Z_][a-zA-Z0-9_]*/) {
56
+ match($i, /(section|block)\.settings\.[a-zA-Z_][a-zA-Z0-9_]*/)
57
+ ref = substr($i, RSTART, RLENGTH)
58
+ gsub(/^(section|block)\.settings\./, "", ref)
59
+ if (schema !~ "\"id\":[ ]*\"" ref "\"") print FILENAME ": unknown settings.'"'"'" ref "'"'"'"
60
+ }
61
+ }
62
+ }
63
+ ' sections/*/index.liquid
64
+ ```
65
+
66
+ (Heuristic — multi-line settings refs and computed keys will produce false positives. Use it to surface candidates, then verify.)
67
+
68
+ **When in doubt, ask one question:** _"What scope is this template in, and was the variable passed into that scope?"_ If the answer is "nothing passed it", it's not available — replace with a settings read or a `render` arg.
69
+
70
+ ### 1. Nil-safety and defaults
71
+
72
+ Liquid is forgiving. `nil.foo` does not raise — it renders empty. That's worse than an error, because the bug ships silently.
73
+
74
+ **`should`** when a `section.settings.*` or `block.settings.*` value is used without `default:` in schema AND without `| default:` in the template:
75
+
76
+ ```liquid
77
+ {%- comment -%} Bad: empty <h2> if heading is blank {%- endcomment -%}
78
+ <h2>{{ section.settings.heading }}</h2>
79
+
80
+ {%- comment -%} Good {%- endcomment -%}
81
+ <h2>{{ section.settings.heading | default: 'Featured products' }}</h2>
82
+ ```
83
+
84
+ **`blocker`** when a method-chain on a resource picker is unguarded and the page can render with a blank picker:
85
+
86
+ ```liquid
87
+ {%- comment -%} Bad: `.first_variant.price` chain blows up if product is blank {%- endcomment -%}
88
+ <span>{{ section.settings.product.first_variant.price | money }}</span>
89
+
90
+ {%- comment -%} Good {%- endcomment -%}
91
+ {%- assign p = section.settings.product -%}
92
+ {%- if p != blank and p.first_variant != blank -%}
93
+ <span>{{ p.first_variant.price | money }}</span>
94
+ {%- endif -%}
95
+ ```
96
+
97
+ ### 2. Whitespace control
98
+
99
+ Liquid keeps every whitespace character outside tags. In tight loops, this inflates HTML by KBs. **`should`** when a `for`/`if` block in markup context lacks `-`:
100
+
101
+ ```liquid
102
+ {%- comment -%} Bad: 6 blank lines per category {%- endcomment -%}
103
+ {% for category in categories %}
104
+ <li>
105
+ {% render 'category_card', category: category %}
106
+ </li>
107
+ {% endfor %}
108
+
109
+ {%- comment -%} Good {%- endcomment -%}
110
+ {%- for category in categories -%}
111
+ <li>{%- render 'category_card', category: category -%}</li>
112
+ {%- endfor -%}
113
+ ```
114
+
115
+ Rule of thumb: control tags (`{%- ... -%}`) get dashes; output tags (`{{ ... }}`) usually don't, except when adjacent to control tags.
116
+
117
+ ### 3. Unclosed tags / mismatched conditionals
118
+
119
+ **`blocker`.** Liquid's parser is permissive — an unclosed `{% if %}` will consume the rest of the template silently. Quick count check:
120
+
121
+ ```bash
122
+ grep -cE '{%-?\s*(if|for|case|capture|comment|unless)\b' file.liquid
123
+ grep -cE '{%-?\s*end(if|for|case|capture|comment|unless)\b' file.liquid
124
+ ```
125
+
126
+ The two counts must match.
127
+
128
+ ### 4. Filter ordering
129
+
130
+ Filters apply left-to-right. **`blocker`** when `escape` is applied before a sanitizer (re-escapes already-escaped entities) or after `truncate` (cuts in the middle of an entity, producing `&am`).
131
+
132
+ ```liquid
133
+ {%- comment -%} Bad: truncate cuts inside &amp; {%- endcomment -%}
134
+ {{ user_content | escape | truncate: 50 }}
135
+
136
+ {%- comment -%} Good: truncate first, escape last {%- endcomment -%}
137
+ {{ user_content | truncate: 50 | escape }}
138
+ ```
139
+
140
+ ### 5. `forloop.first` / `forloop.last` instead of index branching
141
+
142
+ **`nit`** — but worth pointing out for performance + readability:
143
+
144
+ ```liquid
145
+ {%- comment -%} Bad {%- endcomment -%}
146
+ {%- for item in items -%}
147
+ {% if forloop.index == 1 %}<div class="first">{% endif %}
148
+ ...
149
+ {%- endfor -%}
150
+
151
+ {%- comment -%} Good {%- endcomment -%}
152
+ {%- for item in items -%}
153
+ {% if forloop.first %}<div class="first">{% endif %}
154
+ ...
155
+ {%- endfor -%}
156
+ ```
157
+
158
+ ### 6. `assign` inside a loop for a constant value
159
+
160
+ **`should`.** Hoist invariants out:
161
+
162
+ ```liquid
163
+ {%- comment -%} Bad: assigned every iteration {%- endcomment -%}
164
+ {%- for product in products -%}
165
+ {%- assign card_class = 'card card--' | append: section.settings.style -%}
166
+ <div class="{{ card_class }}">...</div>
167
+ {%- endfor -%}
168
+
169
+ {%- comment -%} Good {%- endcomment -%}
170
+ {%- assign card_class = 'card card--' | append: section.settings.style -%}
171
+ {%- for product in products -%}
172
+ <div class="{{ card_class }}">...</div>
173
+ {%- endfor -%}
174
+ ```
175
+
176
+ ---
@@ -0,0 +1,88 @@
1
+ # Navigation — link_list menus
2
+
3
+ > Part of the `themes-review` skill. See [`../SKILL.md`](../SKILL.md) for the review workflow, severity ladder, and validator rules.
4
+
5
+ ## Contents
6
+
7
+ - Bad — hardcoded nav
8
+ - Good — one `link_list` setting
9
+ - Findings to surface
10
+ - When to keep nav items singular
11
+
12
+
13
+ Any series of `<a>` tags that represents a navigation (header nav, footer columns, mobile drawer, breadcrumbs, social links) belongs in a `link_list` setting — **the engine's menu selector**. Hardcoding nav items locks the company into a code change for every new link.
14
+
15
+ `link_list` is one of the canonical setting types (a single-resource picker). It points at a _menu_ the company configures in admin; the theme reads it as an iterable of menu items.
16
+
17
+ ### Bad — hardcoded nav
18
+
19
+ ```liquid
20
+ <nav class="main-nav">
21
+ <a href="/shop">Shop</a>
22
+ <a href="/about">About</a>
23
+ <a href="/blog">Blog</a>
24
+ <a href="/contact">Contact</a>
25
+ </nav>
26
+ ```
27
+
28
+ Or this — better, but still wrong: company-level text/URL settings paired into "nav items":
29
+
30
+ ```json
31
+ {
32
+ "settings": [
33
+ { "type": "text", "id": "nav_label_1", "label": "Item 1 label" },
34
+ { "type": "url", "id": "nav_url_1", "label": "Item 1 link" },
35
+ { "type": "text", "id": "nav_label_2", "label": "Item 2 label" },
36
+ { "type": "url", "id": "nav_url_2", "label": "Item 2 link" }
37
+ ]
38
+ }
39
+ ```
40
+
41
+ ### Good — one `link_list` setting
42
+
43
+ ```json
44
+ {
45
+ "settings": [
46
+ {
47
+ "type": "link_list",
48
+ "id": "menu",
49
+ "label": "Menu",
50
+ "default": "main-menu"
51
+ }
52
+ ]
53
+ }
54
+ ```
55
+
56
+ ```liquid
57
+ {%- assign menu = section.settings.menu -%}
58
+ <nav class="main-nav" aria-label="Main navigation">
59
+ {%- for link in menu.links -%}
60
+ <a href="{{ link.url }}"
61
+ class="main-nav__link {% if link.active %}is-active{% endif %}">
62
+ {{ link.title | escape }}
63
+ </a>
64
+ {%- endfor -%}
65
+ </nav>
66
+ ```
67
+
68
+ The company now picks an existing menu in admin (the editor surfaces all menus the store has configured), and adds/reorders/renames items without ever touching code. Multi-level dropdowns work via `link.links` (children).
69
+
70
+ ### Findings to surface
71
+
72
+ | You see | Severity | Fix |
73
+ | ----------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
74
+ | 2+ hardcoded `<a href="/...">` tags that visually form a navigation (header, footer columns, mobile drawer, social row) | `should` | Replace with a `link_list` setting and a `{% for link in menu.links %}` loop. |
75
+ | Multiple parallel `text` + `url` settings used to fake a menu (`nav_label_1` / `nav_url_1`, `nav_label_2` / `nav_url_2`, ...) | `should` | Collapse to one `link_list`. Each menu item gains drag-reorder, depth, and active-state tracking. |
76
+ | Section that lists footer columns by hardcoding the same column twice | `should` | One block per column with a `link_list` inside the block. Users add/remove/rename columns. |
77
+ | Iterating `linklists` (the global) without exposing a `link_list` picker | `nit` | Acceptable for "site-wide menu" cases, but giving the user a `link_list` setting is more flexible — the section can be reused for different menus. |
78
+ | Breadcrumbs, social-link rows, related-links rails — anything that's a list of `{ label, url }` pairs | `should` | All belong in a `link_list`. Don't reinvent. |
79
+
80
+ ### When to keep nav items singular
81
+
82
+ The `link_list` heuristic does not apply to:
83
+
84
+ - **Single CTAs** in marketing sections — these are real "hero button" / "secondary button" roles. A standalone `text` + `url` (or one `link_list` with `limit: 1` if you want consistency) is fine.
85
+ - **Brand logo links** — the logo's `href` is almost always "go home"; a fixed `/` is acceptable, or expose a single `url` setting.
86
+ - **Legal/footer links that are _required by policy_** (Terms, Privacy) and not the company's call to omit — but even these are usually better as a `link_list` so they can be reordered or extended.
87
+
88
+ ---
@@ -0,0 +1,85 @@
1
+ # Performance
2
+
3
+ > Part of the `themes-review` skill. See [`../SKILL.md`](../SKILL.md) for the review workflow, severity ladder, and validator rules.
4
+
5
+ ## Contents
6
+
7
+ - 1. `asset_url` inside a loop
8
+ - 2. Unbounded `for` over a large collection
9
+ - 3. `sort` on large arrays
10
+ - 4. Repeated `section.settings.X` access
11
+ - 5. JS without `defer`
12
+ - 6. CSS scoping
13
+
14
+
15
+ ### 1. `asset_url` inside a loop
16
+
17
+ **`blocker`** if it generates a unique URL per iteration, **`should`** if the URL is the same every time. Either way, hoist:
18
+
19
+ ```liquid
20
+ {%- comment -%} Bad: same URL resolved N times {%- endcomment -%}
21
+ {%- for product in products -%}
22
+ <img src="{{ 'placeholder.png' | asset_url }}" />
23
+ {%- endfor -%}
24
+
25
+ {%- comment -%} Good {%- endcomment -%}
26
+ {%- assign placeholder_src = 'placeholder.png' | asset_url -%}
27
+ {%- for product in products -%}
28
+ <img src="{{ placeholder_src }}" />
29
+ {%- endfor -%}
30
+ ```
31
+
32
+ ### 2. Unbounded `for` over a large collection
33
+
34
+ **`should`** when iterating `collection.products` or any collection that can grow unbounded without `limit:`:
35
+
36
+ ```liquid
37
+ {%- comment -%} Bad {%- endcomment -%}
38
+ {%- for product in collection.products -%} ... {%- endfor -%}
39
+
40
+ {%- comment -%} Good {%- endcomment -%}
41
+ {%- for product in collection.products limit: 12 -%} ... {%- endfor -%}
42
+ ```
43
+
44
+ For paginated views, use `{% paginate %}`.
45
+
46
+ ### 3. `sort` on large arrays
47
+
48
+ **`should`.** Liquid `sort` is in-memory and O(n log n) every render. Pre-sort at the data layer when possible, or cache via `{% capture %}` after the first sort.
49
+
50
+ ### 4. Repeated `section.settings.X` access
51
+
52
+ **`nit`/`should`.** If the same setting is read 5+ times in markup, alias it once at the top:
53
+
54
+ ```liquid
55
+ {%- assign show_title = section.settings.show_title -%}
56
+ {%- assign title_tag = section.settings.title_tag | default: 'h2' -%}
57
+ ```
58
+
59
+ Improves perf and makes the section's "inputs" readable in one block.
60
+
61
+ ### 5. JS without `defer`
62
+
63
+ **`should`.** Render-blocking script tags hurt LCP:
64
+
65
+ ```liquid
66
+ {%- comment -%} Bad {%- endcomment -%}
67
+ <script src="{{ 'cart_page.js' | asset_url }}"></script>
68
+
69
+ {%- comment -%} Good {%- endcomment -%}
70
+ <script src="{{ 'cart_page.js' | asset_url }}" defer></script>
71
+ ```
72
+
73
+ Exception: scripts that must run before DOM (rare in themes).
74
+
75
+ ### 6. CSS scoping
76
+
77
+ **`should`.** Per-section CSS belongs in the section file:
78
+
79
+ ```liquid
80
+ {{ 'cart_page.css' | asset_url | stylesheet_tag }}
81
+ ```
82
+
83
+ Loading it in `layouts/theme.liquid` ships it on every page. The engine deduplicates stylesheets across the page, so it's safe to declare per-section.
84
+
85
+ ---
@@ -0,0 +1,70 @@
1
+ # Security & accessibility
2
+
3
+ > Part of the `themes-review` skill. See [`../SKILL.md`](../SKILL.md) for the review workflow, severity ladder, and validator rules.
4
+
5
+ ## Contents
6
+
7
+ - 1. Unescaped user content
8
+ - 2. Raw output of URLs in `href` / `src`
9
+ - 3. Comments with secrets
10
+ - 1. Missing `alt` on images
11
+ - 2. Heading hierarchy
12
+ - 3. Button vs link semantics
13
+ - 4. `decoding="async"` and `loading="lazy"`
14
+
15
+
16
+ ### 1. Unescaped user content
17
+
18
+ **`blocker`.** Any string that originated from a user (product title, comment, custom heading) outside of a `richtext`/`html`/`html_textarea` field must be escaped. Default to `| escape` for any string field. The only exception is the HTML-shaped types — those are HTML by design.
19
+
20
+ ```liquid
21
+ <h2>{{ product.title | escape }}</h2>
22
+ ```
23
+
24
+ ### 2. Raw output of URLs in `href` / `src`
25
+
26
+ **`should`.** Escape:
27
+
28
+ ```liquid
29
+ <a href="{{ section.settings.link_url | escape }}">Link</a>
30
+ ```
31
+
32
+ ### 3. Comments with secrets
33
+
34
+ **`should`.** `{% comment %}` blocks are not rendered, but they _are_ shipped to clients if the theme source is exposed (CDN, git). Don't paste tokens or internal URLs in comments.
35
+
36
+ ---
37
+
38
+ ## Accessibility
39
+
40
+ `should` by default. Bump to `blocker` if the section is the storefront homepage or checkout flow.
41
+
42
+ ### 1. Missing `alt` on images
43
+
44
+ ```liquid
45
+ {%- comment -%} Bad {%- endcomment -%}
46
+ <img src="{{ product.featured_image }}" />
47
+
48
+ {%- comment -%} Good {%- endcomment -%}
49
+ <img src="{{ product.featured_image }}" alt="{{ product.title | escape }}" />
50
+ ```
51
+
52
+ Decorative images: `alt=""`.
53
+
54
+ ### 2. Heading hierarchy
55
+
56
+ A section shouldn't skip from `h1` to `h4`. Make heading tag a setting and default sensibly (`h2` for section titles, `h3` for cards).
57
+
58
+ ### 3. Button vs link semantics
59
+
60
+ A clickable `<div>` is `blocker`. Use `<button>` for actions, `<a href>` for navigation.
61
+
62
+ ### 4. `decoding="async"` and `loading="lazy"`
63
+
64
+ ```liquid
65
+ <img src="..." alt="..." loading="lazy" decoding="async" />
66
+ ```
67
+
68
+ Don't set `loading="lazy"` on hero/above-the-fold images — it delays LCP.
69
+
70
+ ---