ultimate-jekyll-manager 1.7.2 → 1.8.1

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.
Files changed (84) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/CLAUDE.md +36 -15
  3. package/README.md +4 -2
  4. package/TODO-AUTH-TESTING.md +1 -1
  5. package/dist/assets/themes/newsflash/README.md +58 -0
  6. package/dist/assets/themes/newsflash/_config.scss +138 -0
  7. package/dist/assets/themes/newsflash/_theme.js +27 -0
  8. package/dist/assets/themes/newsflash/_theme.scss +37 -0
  9. package/dist/assets/themes/newsflash/css/base/_mixins.scss +50 -0
  10. package/dist/assets/themes/newsflash/css/base/_root.scss +134 -0
  11. package/dist/assets/themes/newsflash/css/base/_typography.scss +49 -0
  12. package/dist/assets/themes/newsflash/css/base/_utilities.scss +58 -0
  13. package/dist/assets/themes/newsflash/css/components/_badges.scss +65 -0
  14. package/dist/assets/themes/newsflash/css/components/_buttons.scss +139 -0
  15. package/dist/assets/themes/newsflash/css/components/_cards.scss +52 -0
  16. package/dist/assets/themes/newsflash/css/components/_editorial.scss +182 -0
  17. package/dist/assets/themes/newsflash/css/components/_forms.scss +75 -0
  18. package/dist/assets/themes/newsflash/css/components/_infinite-scroll.scss +102 -0
  19. package/dist/assets/themes/newsflash/css/components/_panels.scss +91 -0
  20. package/dist/assets/themes/newsflash/css/components/_ticker.scss +70 -0
  21. package/dist/assets/themes/newsflash/css/layout/_general.scss +264 -0
  22. package/dist/assets/themes/newsflash/css/layout/_navigation.scss +164 -0
  23. package/dist/assets/themes/newsflash/js/initialize-tooltips.js +20 -0
  24. package/dist/assets/themes/newsflash/js/masthead-scroll.js +29 -0
  25. package/dist/assets/themes/newsflash/pages/404/index.scss +27 -0
  26. package/dist/assets/themes/newsflash/pages/about/index.scss +70 -0
  27. package/dist/assets/themes/newsflash/pages/blog/index.scss +17 -0
  28. package/dist/assets/themes/newsflash/pages/blog/post.js +29 -0
  29. package/dist/assets/themes/newsflash/pages/blog/post.scss +164 -0
  30. package/dist/assets/themes/newsflash/pages/index.scss +159 -0
  31. package/dist/assets/themes/newsflash/pages/pricing/index.scss +194 -0
  32. package/dist/assets/themes/newsflash/pages/test/libraries/layers/index.js +9 -0
  33. package/dist/assets/themes/newsflash/pages/test/libraries/layers/index.scss +7 -0
  34. package/dist/commands/blogify.js +6 -3
  35. package/dist/commands/test.js +34 -5
  36. package/dist/defaults/CLAUDE.md +17 -4
  37. package/dist/defaults/dist/_includes/core/pricing/resolve-plan.html +59 -0
  38. package/dist/defaults/dist/_includes/themes/classy/frontend/sections/footer.html +20 -3
  39. package/dist/defaults/dist/_layouts/themes/classy/admin/core/minimal-viewport-locked.html +1 -1
  40. package/dist/defaults/dist/_layouts/themes/classy/admin/core/minimal.html +1 -1
  41. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/pricing.html +5 -40
  42. package/dist/defaults/dist/_layouts/themes/neobrutalism/frontend/pages/pricing.html +33 -34
  43. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/core/base.html +61 -0
  44. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/404.html +86 -0
  45. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/about.html +353 -0
  46. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/blog/categories/category.html +105 -0
  47. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/blog/categories/index.html +93 -0
  48. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/blog/index.html +373 -0
  49. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/blog/post.html +289 -0
  50. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/blog/tags/index.html +90 -0
  51. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/blog/tags/tag.html +107 -0
  52. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/contact.html +340 -0
  53. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/index.html +522 -0
  54. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/pricing.html +485 -0
  55. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/team/index.html +207 -0
  56. package/dist/defaults/dist/_layouts/themes/newsflash/frontend/pages/team/member.html +134 -0
  57. package/dist/defaults/test/README.md +4 -0
  58. package/dist/gulp/tasks/jekyll.js +4 -2
  59. package/dist/test/runner.js +50 -3
  60. package/dist/test/suites/build/attach-log-file.test.js +102 -0
  61. package/dist/test/suites/build/theme-contract.test.js +173 -0
  62. package/dist/test/utils/extended-mode-warning.js +13 -0
  63. package/dist/utils/attach-log-file.js +70 -43
  64. package/docs/appearance.md +1 -0
  65. package/docs/assets.md +9 -0
  66. package/docs/audit.md +78 -7
  67. package/docs/build-system.md +57 -0
  68. package/docs/common-mistakes.md +15 -0
  69. package/docs/{project-structure.md → directory-structure.md} +1 -1
  70. package/docs/environment-detection.md +1 -1
  71. package/docs/javascript-libraries.md +38 -1
  72. package/docs/layouts-and-pages.md +146 -0
  73. package/docs/local-development.md +1 -8
  74. package/docs/logging.md +30 -0
  75. package/docs/migration.md +131 -0
  76. package/docs/no-inline-scripts.md +304 -0
  77. package/docs/purgecss.md +164 -0
  78. package/docs/seo.md +131 -4
  79. package/docs/templating.md +23 -0
  80. package/docs/test-boot-layer.md +1 -1
  81. package/docs/test-framework.md +56 -8
  82. package/docs/themes.md +254 -13
  83. package/logs/test.log +111 -0
  84. package/package.json +9 -8
@@ -0,0 +1,194 @@
1
+ // Newsflash Theme — Pricing page-specific CSS
2
+ // The "subscription desk" treatment for
3
+ // (_layouts/themes/newsflash/frontend/pages/pricing.html): pill billing
4
+ // toggle, framed plan cards with the vermilion "Editor's pick", and the
5
+ // enterprise strip. Compiles standalone — theme tokens + mixins via loadPaths.
6
+ @use 'config' as *;
7
+ @import 'css/base/mixins';
8
+
9
+ // ============================================
10
+ // Billing toggle — pill segmented control
11
+ // ============================================
12
+ .billing-toggle {
13
+ display: flex;
14
+ justify-content: center;
15
+ gap: 0.35rem;
16
+ width: fit-content;
17
+ margin: 0 auto 1.25rem;
18
+ padding: 0.35rem;
19
+ border: var(--nf-border);
20
+ border-radius: 50rem;
21
+ background: var(--bs-card-bg);
22
+
23
+ .billing-option {
24
+ display: inline-flex;
25
+ align-items: center;
26
+ gap: 0.5em;
27
+ padding: 0.5rem 1.25rem;
28
+ border-radius: 50rem;
29
+ font-weight: 700;
30
+ font-size: 0.9rem;
31
+ cursor: pointer;
32
+ transition: $nf-transition;
33
+
34
+ &:hover {
35
+ background: var(--nf-paper-2);
36
+ }
37
+ }
38
+
39
+ .btn-check:checked + .billing-option {
40
+ background: var(--nf-ink);
41
+ color: var(--nf-paper);
42
+ }
43
+
44
+ .billing-save {
45
+ font-size: 0.68rem;
46
+ font-weight: 800;
47
+ letter-spacing: 0.06em;
48
+ text-transform: uppercase;
49
+ padding: 0.3em 0.7em;
50
+ border-radius: 50rem;
51
+ background: var(--nf-volt);
52
+ color: var(--nf-volt-ink);
53
+ }
54
+ }
55
+
56
+ // ============================================
57
+ // Plan grid + cards
58
+ // ============================================
59
+ .pricing-plan-grid {
60
+ display: grid;
61
+ grid-template-columns: repeat(4, 1fr);
62
+ gap: 1.5rem;
63
+ align-items: start;
64
+ margin-bottom: 2rem;
65
+
66
+ @media (max-width: 1199.98px) {
67
+ grid-template-columns: repeat(2, 1fr);
68
+ }
69
+
70
+ @media (max-width: 767.98px) {
71
+ grid-template-columns: 1fr;
72
+ }
73
+ }
74
+
75
+ .pricing-plan {
76
+ position: relative;
77
+ padding: 1.75rem;
78
+
79
+ &--popular {
80
+ border-color: var(--bs-primary);
81
+ border-width: 2.5px;
82
+ box-shadow: var(--nf-shadow-hard-hover);
83
+ }
84
+ }
85
+
86
+ .pricing-plan-flag {
87
+ position: absolute;
88
+ top: -0.85rem;
89
+ left: 50%;
90
+ transform: translateX(-50%) rotate(-2deg);
91
+ white-space: nowrap;
92
+ }
93
+
94
+ .pricing-plan-name {
95
+ font-size: 1.4rem;
96
+ margin-bottom: 0.1rem;
97
+ }
98
+
99
+ .pricing-plan-tagline {
100
+ margin-bottom: 1.25rem;
101
+ }
102
+
103
+ .pricing-plan-price {
104
+ font-family: $nf-font-display;
105
+ margin-bottom: 0.25rem;
106
+
107
+ .pricing-plan-currency {
108
+ font-size: 1.4rem;
109
+ font-weight: 600;
110
+ vertical-align: super;
111
+ }
112
+
113
+ .pricing-plan-amount {
114
+ font-size: 3rem;
115
+ font-weight: 600;
116
+ letter-spacing: -0.02em;
117
+ line-height: 1;
118
+ }
119
+
120
+ .pricing-plan-per {
121
+ font-family: $font-family-sans-serif;
122
+ font-size: 0.95rem;
123
+ color: var(--bs-secondary-color);
124
+ }
125
+ }
126
+
127
+ .pricing-plan-ppu {
128
+ font-size: 0.85rem;
129
+ color: var(--bs-secondary-color);
130
+ min-height: 1.3em;
131
+ margin-bottom: 1.25rem;
132
+
133
+ .price-per-unit {
134
+ font-weight: 700;
135
+ color: var(--bs-primary);
136
+ }
137
+ }
138
+
139
+ .pricing-plan-billing,
140
+ .pricing-plan-guarantee {
141
+ font-size: 0.8rem;
142
+ color: var(--bs-secondary-color);
143
+ text-align: center;
144
+ margin-bottom: 0.35rem;
145
+ }
146
+
147
+ .pricing-plan-rule {
148
+ margin: 1.25rem 0;
149
+ }
150
+
151
+ .pricing-plan-inherit {
152
+ font-size: 0.8rem;
153
+ font-weight: 800;
154
+ letter-spacing: 0.08em;
155
+ text-transform: uppercase;
156
+ color: var(--bs-secondary-color);
157
+ margin-bottom: 0.75rem;
158
+ }
159
+
160
+ .pricing-plan-features {
161
+ list-style: none;
162
+ padding: 0;
163
+ margin: 0 0 1rem;
164
+
165
+ li {
166
+ display: flex;
167
+ gap: 0.6em;
168
+ align-items: baseline;
169
+ padding: 0.3rem 0;
170
+ font-size: 0.92rem;
171
+ }
172
+
173
+ .pricing-plan-feature-icon {
174
+ color: var(--bs-primary);
175
+ flex: none;
176
+ }
177
+ }
178
+
179
+ // ============================================
180
+ // Enterprise strip
181
+ // ============================================
182
+ .enterprise-panel {
183
+ display: flex;
184
+ flex-direction: row;
185
+ align-items: center;
186
+ justify-content: space-between;
187
+ gap: 1.5rem;
188
+ padding: 1.75rem;
189
+
190
+ @media (max-width: 767.98px) {
191
+ flex-direction: column;
192
+ align-items: flex-start;
193
+ }
194
+ }
@@ -0,0 +1,9 @@
1
+ // /test — Theme (newsflash) page JS — the #theme layer.
2
+ // Runs AFTER #main, BEFORE #project. Turns the "js-theme" dot green.
3
+ export default ({ manager, options }) => {
4
+ const dot = document.querySelector('.layer-dot[data-layer="js-theme"]');
5
+ if (dot) {
6
+ dot.style.background = '#30a46c'; // green
7
+ }
8
+ console.log('[test-layer] #theme JS ran → js-theme dot green');
9
+ };
@@ -0,0 +1,7 @@
1
+ // /test — Theme (newsflash) page CSS — the #theme layer.
2
+ // Loads AFTER the universal layer and turns the "css-theme" dot green.
3
+ // Compiles standalone, so no theme tokens are needed here — just the proof.
4
+
5
+ .layer-dot[data-layer="css-theme"] {
6
+ background: #30a46c; // green
7
+ }
@@ -276,11 +276,14 @@ module.exports = async function (options) {
276
276
  });
277
277
  }
278
278
 
279
- // Generate 12 posts
279
+ // Generate posts (--count=<n>, default 12)
280
+ const count = parseInt(options.count, 10) || 12;
280
281
  const now = Math.floor(Date.now() / 1000);
281
282
  const dayInSeconds = 86400;
282
283
 
283
- for (let i = 0; i < 12; i++) {
284
+ logger.log(`Generating ${count} test posts...`);
285
+
286
+ for (let i = 0; i < count; i++) {
284
287
  // Calculate timestamp for each post (spread over last 12 days)
285
288
  const postTimestamp = now - (i * dayInSeconds);
286
289
  const postId = `test-${postTimestamp}`; // Add test- prefix to ID
@@ -335,5 +338,5 @@ ${generateBlogContent(postImages)}`;
335
338
  logger.log(`Created post: ${filename}`);
336
339
  }
337
340
 
338
- logger.log(`Successfully created 12 blog posts in ${postsDir}`);
341
+ logger.log(`Successfully created ${count} blog posts in ${postsDir}`);
339
342
  };
@@ -5,15 +5,34 @@ const Manager = require('../build.js');
5
5
  const mgr = new Manager();
6
6
  const logger = mgr.logger('test');
7
7
  const { run } = require('../test/runner.js');
8
+ const attachLogFile = require('../utils/attach-log-file.js');
9
+ const { EXTENDED_MODE_WARNING } = require('../test/utils/extended-mode-warning.js');
8
10
 
9
11
  module.exports = async function (options) {
12
+ // Tee all test output to <projectRoot>/logs/test.log (ANSI-stripped) — mirrors
13
+ // the dev/build log pattern and EM/BEM's test.log. Skipped on CI via isServer().
14
+ attachLogFile('test');
15
+
10
16
  const layer = options.layer || 'all';
17
+ // Positional target: `npx mgr test <target>` where target supports source
18
+ // prefixes — `project:`, `project:<path>`, `mgr:`, `ujm:`, or a bare `<path>`.
19
+ const target = (options._ && options._[1]) || null;
20
+ // `--filter` flag: substring match on test NAMES/descriptions (orthogonal to target).
11
21
  const filter = options.filter || null;
12
22
  const reporter = options.reporter || 'pretty';
13
- const integration = options.integration === true || options.integration === 'true';
23
+ // Extended mode opt into tests that hit REAL external services (network fetches, Firebase
24
+ // via web-manager, live APIs) instead of skipping them. Off by default so `npx mgr test`
25
+ // stays fast and offline-safe. The canonical signal is the unprefixed `TEST_EXTENDED_MODE`
26
+ // env var — the SAME name across BEM/BXM/UJM/EM (cross-framework parity); `--extended` is the
27
+ // CLI shorthand. Once set on process.env it propagates to every spawned child (the Jekyll
28
+ // build, the boot HTTP server / Puppeteer browsers) automatically via inherited `process.env`.
29
+ const extended = options.extended === true
30
+ || options.extended === 'true'
31
+ || process.env.TEST_EXTENDED_MODE === 'true'
32
+ || process.env.TEST_EXTENDED_MODE === '1';
14
33
 
15
- if (integration) {
16
- process.env.UJ_TEST_INTEGRATION = '1';
34
+ if (extended) {
35
+ process.env.TEST_EXTENDED_MODE = 'true';
17
36
  }
18
37
 
19
38
  // Canonical signal — every Manager picks this up via isTesting().
@@ -33,10 +52,15 @@ module.exports = async function (options) {
33
52
  }
34
53
 
35
54
  if (reporter !== 'json') {
36
- logger.log(`Running tests (layer=${layer}${filter ? ` filter="${filter}"` : ''}${integration ? ' +integration' : ''})`);
55
+ logger.log(`Running tests (layer=${layer}${target ? ` target="${target}"` : ''}${filter ? ` filter="${filter}"` : ''}${extended ? ' +extended' : ''})`);
56
+ logger.log(`Test mode: ${extended ? 'extended (real external APIs)' : 'normal (external APIs skipped)'}`);
57
+ if (extended) {
58
+ logger.warn(EXTENDED_MODE_WARNING[0]);
59
+ EXTENDED_MODE_WARNING.slice(1).forEach((line) => logger.warn(line));
60
+ }
37
61
  }
38
62
 
39
- const result = await run({ layer, filter, reporter });
63
+ const result = await run({ layer, target, filter, reporter });
40
64
 
41
65
  if (reporter === 'json') {
42
66
  // Final machine-readable summary.
@@ -51,6 +75,11 @@ module.exports = async function (options) {
51
75
 
52
76
  if (result.failed > 0) {
53
77
  process.exitCode = 1;
78
+ attachLogFile.detach();
54
79
  throw new Error(`${result.failed} test(s) failed`);
55
80
  }
81
+
82
+ // Restore stdout/stderr and close the log file. UJM's util writes synchronously,
83
+ // so the tail is already on disk — this just cleans up the handle.
84
+ attachLogFile.detach();
56
85
  };
@@ -28,9 +28,12 @@ npm start # dev: clean → setup → bundle exec gulp serve (Jekyll +
28
28
  npm run build # production build (UJ_BUILD_MODE=true): clean → setup → full gulp pipeline → _site/
29
29
  npm run deploy # build → `npu sync --message='Deploy'` (publishes _site/)
30
30
  npx mgr test # run framework + project test suites (build / page / boot layers)
31
- npx mgr test pages/home # run a specific test by path (relative to test/)
32
- npx mgr test ujm:pages/home # run only framework tests matching a path
33
- npx mgr test project:custom-test # run only consumer project tests matching a path
31
+ npx mgr test pages/home # run a specific test by path (relative to test/, both sources)
32
+ npx mgr test project: # run ONLY your project tests (project:custom-test for one path)
33
+ npx mgr test mgr: # run ONLY framework tests (ujm: / framework: are equivalent)
34
+ npx mgr test --filter=foo # match test NAMES within the selected files (composes with target)
35
+ npx mgr test --extended # also run tests that hit real external services (or TEST_EXTENDED_MODE=true; off by default)
36
+ # (output is teed to logs/ — dev.log on `npm start`, build.log on `npm run build`, test.log on `npx mgr test`; cat instead of scrolling scrollback)
34
37
  npx mgr audit # HTML validation + spellcheck + optional Lighthouse
35
38
  npx mgr install dev # use LOCAL ultimate-jekyll-manager source (to test framework edits)
36
39
  npx mgr install live # restore the published ultimate-jekyll-manager from npm
@@ -51,7 +54,7 @@ See `node_modules/ultimate-jekyll-manager/docs/themes.md` for the full "Bootstra
51
54
 
52
55
  ## 🚨 Development workflow — MUST follow
53
56
 
54
- - **🚫 NEVER run `npm start`, `npm run build`, or `npm test`** unless the user explicitly asks. Assume the user is already running the dev server. Running these commands kills the user's process and wastes time.
57
+ - **🚫 NEVER run `npm start`** the user runs the dev server; running it again kills theirs. Assume it's already running; if it isn't, instruct the user to run it rather than running it yourself. Running `npx mgr test` is fine.
55
58
  - **✅ ALWAYS check `logs/dev.log`** after editing source files (SCSS, JS, HTML, config) to confirm the build succeeded. The dev server's gulp watcher recompiles on file change — check the log for errors.
56
59
  - Success: `Reloading Browsers...`
57
60
  - Failure: `'sass' errored`, `'webpack' errored`, `'build-error'`, `'jekyll' errored`
@@ -114,6 +117,16 @@ At build time, `require('ultimate-jekyll-manager/build')` exposes:
114
117
  - `Manager.logger(name)` — timestamped logger instance
115
118
  - `Manager.require(path)` — escape hatch for UJM transitive deps (use sparingly)
116
119
 
120
+ ## Dependency resolution
121
+
122
+ - **Do NOT install framework dependencies directly** (`firebase`, `web-manager`, etc.). UJM's webpack config resolves them through the framework's own `node_modules/`. If something doesn't resolve, the issue is in UJM's webpack config — not your `package.json`.
123
+ - **web-manager owns Firebase.** Never `import firebase from 'firebase/app'`. Use `import webManager from 'web-manager'` → `webManager.auth()`, `webManager.firestore()`.
124
+ - **`Manager.require(name)`** resolves from UJM's module context at runtime for unbundled code (gulp tasks, test fixtures).
125
+
126
+ ## Testing
127
+
128
+ Every feature ships with tests at every layer it has a surface in: **logic** (`test/build/`, or `test/page/` for frontend module logic), **UI** (`test/page/` — real events on the real DOM), and **end-to-end** (`test/boot/`). Skip a layer only when the feature genuinely has no surface there — "the logic test covers it" does not excuse the UI test. See `test/README.md` and `node_modules/ultimate-jekyll-manager/docs/test-framework.md`.
129
+
117
130
  <!-- Everything above this marker is owned by the framework and rewritten on every `npx mgr setup`. Add your project-specific notes below — they are preserved across setups. -->
118
131
 
119
132
  # ========== Custom Values ==========
@@ -0,0 +1,59 @@
1
+ {% comment %}
2
+ Shared plan-pricing resolution — the SSOT for the pricing math every theme's
3
+ pricing layout uses. Jekyll includes share the caller's variable scope, so
4
+ the assigns below land in the calling template.
5
+
6
+ In: include.plan — a page.resolved.pricing.plans item
7
+
8
+ Out: _config_product — matching site.web_manager.payment.products entry (or nil)
9
+ _plan_monthly — monthly price (frontmatter pricing → config prices → 0)
10
+ _plan_annually — annual price (frontmatter pricing → config prices → 0)
11
+ _ppu_value — per-unit feature value (nil unless price_per_unit enabled and plan is paid)
12
+ monthly_price_per_unit — $/unit at monthly billing (nil unless _ppu_value > 0)
13
+ annual_price_per_unit — $/unit at annual billing, monthly-equivalent (nil unless _ppu_value > 0)
14
+
15
+ Every output is (re)assigned on every call so loop iterations never see a
16
+ previous plan's values.
17
+ {% endcomment %}
18
+
19
+ {% assign _config_product = nil %}
20
+ {% for p in site.web_manager.payment.products %}
21
+ {% if p.id == include.plan.id %}{% assign _config_product = p %}{% break %}{% endif %}
22
+ {% endfor %}
23
+
24
+ {% comment %} Prices: frontmatter pricing takes precedence, then config, then 0 {% endcomment %}
25
+ {% if include.plan.pricing.monthly or include.plan.pricing.monthly == 0 %}
26
+ {% assign _plan_monthly = include.plan.pricing.monthly %}
27
+ {% elsif _config_product.prices.monthly or _config_product.prices.monthly == 0 %}
28
+ {% assign _plan_monthly = _config_product.prices.monthly %}
29
+ {% else %}
30
+ {% assign _plan_monthly = 0 %}
31
+ {% endif %}
32
+
33
+ {% if include.plan.pricing.annually or include.plan.pricing.annually == 0 %}
34
+ {% assign _plan_annually = include.plan.pricing.annually %}
35
+ {% elsif _config_product.prices.annually or _config_product.prices.annually == 0 %}
36
+ {% assign _plan_annually = _config_product.prices.annually %}
37
+ {% else %}
38
+ {% assign _plan_annually = 0 %}
39
+ {% endif %}
40
+
41
+ {% comment %} Per-unit value: frontmatter feature value, falling back to config product limits. Per-unit prices only compute when the value is a usable positive number (guards the divided_by) {% endcomment %}
42
+ {% assign _ppu_value = nil %}
43
+ {% assign monthly_price_per_unit = nil %}
44
+ {% assign annual_price_per_unit = nil %}
45
+ {% if page.resolved.pricing.price_per_unit.enabled and _plan_monthly > 0 %}
46
+ {% for feature in include.plan.features %}
47
+ {% if feature.id == page.resolved.pricing.price_per_unit.feature_id %}
48
+ {% assign _ppu_value = feature.value %}
49
+ {% if _config_product and _ppu_value == nil %}
50
+ {% for _lim in _config_product.limits %}{% if _lim[0] == feature.id %}{% assign _ppu_value = _lim[1] %}{% break %}{% endif %}{% endfor %}
51
+ {% endif %}
52
+ {% endif %}
53
+ {% endfor %}
54
+ {% if _ppu_value and _ppu_value > 0 %}
55
+ {% assign monthly_price_per_unit = _plan_monthly | times: 1.0 | divided_by: _ppu_value | round: 2 %}
56
+ {% assign annual_monthly_price = _plan_annually | divided_by: 12.0 %}
57
+ {% assign annual_price_per_unit = annual_monthly_price | divided_by: _ppu_value | round: 2 %}
58
+ {% endif %}
59
+ {% endif %}
@@ -8,6 +8,9 @@
8
8
  {% capture logo_src %}{% if data.logo.src %}{{ data.logo.src }}{% else %}{{ site.brand.images.brandmark | default: null }}{% endif %}{% endcapture %}
9
9
  {% capture logo_text %}{{ data.logo.text | default: site.brand.name | uj_liquify }}{% endcapture %}
10
10
  {% capture logo_description %}{{ data.logo.description | default: site.brand.description | uj_liquify }}{% endcapture %}
11
+ {% assign logo_description = logo_description | strip %}
12
+ {% comment %} A data value that LIQUIFIES to empty (e.g. '{{ site.meta.description }}' with no meta.description set) still needs the brand fallback — `default:` only sees the raw string {% endcomment %}
13
+ {% if logo_description == '' or logo_description == 'null' %}{% assign logo_description = site.brand.description %}{% endif %}
11
14
  {% capture logo_class %}{{ data.logo.class }}{% endcapture %}
12
15
 
13
16
  <div class="h5 mb-3">
@@ -76,9 +79,7 @@
76
79
  <div class="d-flex align-items-center me-3">
77
80
  <div class="dropup uj-language-dropdown">
78
81
  <button class="btn btn-sm btn-outline-adaptive dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
79
- <i class="fa fa-sm me-1">
80
- <?xml version="1.0" encoding="iso-8859-1"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1em" height="1em" fill="currentColor" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve"> <path style="fill:#DAE2F2;" d="M467,91H237.25l-34.528,415.796c6.068,3.034,12.594,5.204,19.763,5.204H467c24.814,0,45-20.186,45-45 V136C512,111.186,491.814,91,467,91z"/> <path style="fill:#4D97FF;" d="M169.867,407.86l7.969,64.721c1.6,12.854,10.3,26.922,24.886,34.215 c29.167-31.374,9.614-11.341,78.829-85.796L169.867,407.86z"/> <path style="fill:#4DB5FF;" d="M281.551,421c4.907-5.608,9.211-9.373,8.053-17.095l-45.44-364.486 C241.366,16.948,222.162,0,199.516,0H45C20.186,0,0,20.186,0,45v331c0,24.814,20.186,45,45,45C123.402,421,202.898,421,281.551,421z "/> <path style="fill:#E6EEFF;" d="M165.707,118.056C164.301,111.054,158.148,106,151,106h-30c-7.148,0-13.301,5.054-14.707,12.056 l-30,150c-1.626,8.13,3.647,16.025,11.763,17.651c8.218,1.685,16.04-3.647,17.651-11.763L115.294,226h41.411l9.587,47.944 c1.641,8.237,9.697,13.4,17.651,11.763c8.115-1.626,13.389-9.521,11.763-17.651L165.707,118.056z M121.293,196l11.997-60h5.42 l11.997,60H121.293z"/> <path style="fill:#53565C;" d="M436,226h-45v-15c0-8.291-6.709-15-15-15s-15,6.709-15,15v15h-45c-8.291,0-15,6.709-15,15 s6.709,15,15,15h4.006c8.535,27.383,21.07,48.81,35.136,65.702c-11.019,10.074-21.802,18.339-33.518,27.594 c-6.459,5.171-7.514,14.604-2.328,21.079c5.162,6.465,14.632,7.514,21.078,2.329c12.73-10.047,23.679-18.456,35.626-29.421 c11.947,10.966,22.896,19.375,35.624,29.421c6.448,5.185,15.918,4.136,21.08-2.329c5.186-6.475,4.131-15.908-2.33-21.079 c-11.715-9.255-22.498-17.52-33.517-27.594c14.066-16.891,26.602-38.318,35.136-65.702H436c8.291,0,15-6.709,15-15 S444.291,226,436,226z M376,299.467c-9.534-11.984-18.149-26.069-24.626-43.467h49.252C394.149,273.399,385.534,287.483,376,299.467 z"/> <g> </g> <g> </g> <g> </g> <g> </g> <g> </g> <g> </g> <g> </g> <g> </g> <g> </g> <g> </g> <g> </g> <g> </g> <g> </g> <g> </g> <g> </g> </svg>
81
- </i>
82
+ {% uj_icon "language", "fa-sm me-1" %}
82
83
  <span class="_text-body">
83
84
  Language
84
85
  </span>
@@ -103,6 +104,22 @@
103
104
  </div>
104
105
  </div>
105
106
 
107
+ <!-- Appearance Dropdown (logic handled framework-side via data-appearance-* attributes — see docs/appearance.md) -->
108
+ <div class="d-flex align-items-center me-3">
109
+ <div class="dropup uj-appearance-dropdown">
110
+ <button class="btn btn-sm btn-outline-adaptive dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Appearance">
111
+ <span data-appearance-icon="light" hidden>{% uj_icon "sun", "fa-sm" %}</span>
112
+ <span data-appearance-icon="dark" hidden>{% uj_icon "moon-stars", "fa-sm" %}</span>
113
+ <span data-appearance-icon="system" hidden>{% uj_icon "circle-half-stroke", "fa-sm" %}</span>
114
+ </button>
115
+ <ul class="dropdown-menu">
116
+ <li><button class="dropdown-item" type="button" data-appearance-set="light">{% uj_icon "sun", "fa-sm me-2" %}Light</button></li>
117
+ <li><button class="dropdown-item" type="button" data-appearance-set="dark">{% uj_icon "moon-stars", "fa-sm me-2" %}Dark</button></li>
118
+ <li><button class="dropdown-item" type="button" data-appearance-set="system">{% uj_icon "circle-half-stroke", "fa-sm me-2" %}System</button></li>
119
+ </ul>
120
+ </div>
121
+ </div>
122
+
106
123
  <!-- Social Links -->
107
124
  {% if data.socials.enabled %}
108
125
  <div class="d-flex align-items-center">
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  ### ALL PAGES ###
3
- layout: themes/classy/backend/core/minimal-viewport-locked
3
+ layout: themes/[ site.theme.id ]/backend/core/minimal-viewport-locked
4
4
 
5
5
  ### THEME CONFIG ###
6
6
  theme:
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  ### ALL PAGES ###
3
- layout: themes/classy/backend/core/minimal
3
+ layout: themes/[ site.theme.id ]/backend/core/minimal
4
4
 
5
5
  ### THEME CONFIG ###
6
6
  theme:
@@ -304,31 +304,8 @@ faqs:
304
304
 
305
305
  <div class="row g-4 mb-5 justify-content-center">
306
306
  {% for plan in page.resolved.pricing.plans %}
307
- {% comment %} Look up matching product in _config.yml payment products {% endcomment %}
308
- {% assign _config_product = nil %}
309
- {% for p in site.web_manager.payment.products %}
310
- {% if p.id == plan.id %}
311
- {% assign _config_product = p %}
312
- {% break %}
313
- {% endif %}
314
- {% endfor %}
315
-
316
- {% comment %} Resolve prices: frontmatter pricing takes precedence, then config prices {% endcomment %}
317
- {% if plan.pricing.monthly or plan.pricing.monthly == 0 %}
318
- {% assign _plan_monthly = plan.pricing.monthly %}
319
- {% elsif _config_product.prices.monthly or _config_product.prices.monthly == 0 %}
320
- {% assign _plan_monthly = _config_product.prices.monthly %}
321
- {% else %}
322
- {% assign _plan_monthly = 0 %}
323
- {% endif %}
324
-
325
- {% if plan.pricing.annually or plan.pricing.annually == 0 %}
326
- {% assign _plan_annually = plan.pricing.annually %}
327
- {% elsif _config_product.prices.annually or _config_product.prices.annually == 0 %}
328
- {% assign _plan_annually = _config_product.prices.annually %}
329
- {% else %}
330
- {% assign _plan_annually = 0 %}
331
- {% endif %}
307
+ {% comment %} Shared pricing math (assigns _plan_monthly/_plan_annually/per-unit into this scope) {% endcomment %}
308
+ {% include core/pricing/resolve-plan.html plan=plan %}
332
309
 
333
310
  {% if plan.popular %}
334
311
  {% assign border_classes = "border-gradient-rainbow border-3" %}
@@ -365,21 +342,9 @@ faqs:
365
342
  <!-- Price per unit (only shown when enabled) -->
366
343
  {% if page.resolved.pricing.price_per_unit.enabled %}
367
344
  <p class="text-muted small mb-4">
368
- {% if _plan_monthly > 0 %}
369
- {% for feature in plan.features %}
370
- {% if feature.id == page.resolved.pricing.price_per_unit.feature_id %}
371
- {% comment %} Resolve feature value from config limits if not set in frontmatter {% endcomment %}
372
- {% assign _ppu_value = feature.value %}
373
- {% if _config_product and _ppu_value == nil %}
374
- {% for _lim in _config_product.limits %}{% if _lim[0] == feature.id %}{% assign _ppu_value = _lim[1] %}{% break %}{% endif %}{% endfor %}
375
- {% endif %}
376
- {% assign monthly_price_per_unit = _plan_monthly | times: 1.0 | divided_by: _ppu_value | round: 2 %}
377
- {% assign annual_monthly_price = _plan_annually | divided_by: 12.0 %}
378
- {% assign annual_price_per_unit = annual_monthly_price | divided_by: _ppu_value | round: 2 %}
379
- <span class="price-per-unit" data-monthly="${{ monthly_price_per_unit }}" data-annually="${{ annual_price_per_unit }}">${{ annual_price_per_unit }}</span> per {{ page.resolved.pricing.price_per_unit.label }}
380
- {% endif %}
381
- {% endfor %}
382
- {% else %}
345
+ {% if monthly_price_per_unit %}
346
+ <span class="price-per-unit" data-monthly="${{ monthly_price_per_unit }}" data-annually="${{ annual_price_per_unit }}">${{ annual_price_per_unit }}</span> per {{ page.resolved.pricing.price_per_unit.label }}
347
+ {% elsif _plan_monthly == 0 %}
383
348
  <!-- Placeholder text for free plan to maintain consistent height -->
384
349
  Perfect for trying out
385
350
  {% endif %}
@@ -153,6 +153,33 @@ faqs:
153
153
  social-proof, and a full-bleed CTA. See docs/themes.md.
154
154
  {% endcomment %}
155
155
 
156
+ <!-- ============================================ -->
157
+ <!-- PROMO BANNER — revealed + populated by the framework pricing JS
158
+ (sale name, countdown, navbar offset). The ids are the contract;
159
+ ship it hidden and let the JS do the rest. -->
160
+ <!-- ============================================ -->
161
+ <div id="pricing-promo-banner" class="position-fixed top-0 start-0 w-100 text-center animation-slide-down" style="z-index: 1050;" hidden>
162
+ <div class="bg-primary text-white py-2 position-relative">
163
+ <button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 mt-2 me-2" aria-label="Close" onclick="this.closest('#pricing-promo-banner').hidden=true;document.querySelector('.navbar-wrapper').style.marginTop='';document.querySelector('main > section:first-of-type').style.paddingTop=''"></button>
164
+ <div class="container-fluid">
165
+ <div class="d-flex align-items-center justify-content-center gap-3 flex-wrap pe-4">
166
+ <span id="pricing-promo-badge" class="badge bg-warning text-dark px-2 py-1 fs-6 animation-wiggle">
167
+ 15% OFF!
168
+ </span>
169
+ <span id="pricing-promo-text" class="fw-bold">
170
+ Flash sale
171
+ </span>
172
+ <span class="text-white-50">
173
+ Ending in <span id="pricing-promo-countdown" class="fw-semibold text-white">--</span>
174
+ </span>
175
+ <span class="text-white-50">
176
+ Use code <span id="pricing-promo-code" class="badge bg-white bg-opacity-25 px-2 py-1 fs-6">WELCOME15</span>
177
+ </span>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ </div>
182
+
156
183
  <!-- ============================================ -->
157
184
  <!-- HERO -->
158
185
  <!-- ============================================ -->
@@ -208,30 +235,17 @@ faqs:
208
235
 
209
236
  <div class="pricing-plan-grid">
210
237
  {% for plan in page.resolved.pricing.plans %}
211
- {% comment %} Look up matching config product (same as classy) {% endcomment %}
212
- {% assign _config_product = nil %}
213
- {% for p in site.web_manager.payment.products %}
214
- {% if p.id == plan.id %}{% assign _config_product = p %}{% break %}{% endif %}
215
- {% endfor %}
216
-
217
- {% if plan.pricing.monthly or plan.pricing.monthly == 0 %}
218
- {% assign _plan_monthly = plan.pricing.monthly %}
219
- {% elsif _config_product.prices.monthly or _config_product.prices.monthly == 0 %}
220
- {% assign _plan_monthly = _config_product.prices.monthly %}
221
- {% else %}{% assign _plan_monthly = 0 %}{% endif %}
222
-
223
- {% if plan.pricing.annually or plan.pricing.annually == 0 %}
224
- {% assign _plan_annually = plan.pricing.annually %}
225
- {% elsif _config_product.prices.annually or _config_product.prices.annually == 0 %}
226
- {% assign _plan_annually = _config_product.prices.annually %}
227
- {% else %}{% assign _plan_annually = 0 %}{% endif %}
238
+ {% comment %} Shared pricing math (assigns _plan_monthly/_plan_annually/per-unit into this scope) {% endcomment %}
239
+ {% include core/pricing/resolve-plan.html plan=plan %}
228
240
 
229
241
  <article class="card pricing-plan {% if plan.popular %}pricing-plan--popular{% endif %}">
230
242
  {% if plan.popular %}
231
243
  <span class="pricing-plan-flag">Most Popular</span>
232
244
  {% endif %}
233
245
 
234
- <h3 class="pricing-plan-name">{{ plan.name }}</h3>
246
+ <!-- .card-title is load-bearing: the framework pricing JS reads the plan
247
+ name for analytics via `.h3, .h2, .card-title` inside the card. -->
248
+ <h3 class="card-title pricing-plan-name">{{ plan.name }}</h3>
235
249
  <p class="pricing-plan-tagline">{{ plan.tagline }}</p>
236
250
 
237
251
  <!-- Price -->
@@ -244,23 +258,8 @@ faqs:
244
258
  </div>
245
259
 
246
260
  {% if page.resolved.pricing.price_per_unit.enabled %}
247
- {% comment %} Resolve the per-unit value; only render if it's a usable positive number {% endcomment %}
248
- {% assign _ppu_value = nil %}
249
- {% if _plan_monthly > 0 %}
250
- {% for feature in plan.features %}
251
- {% if feature.id == page.resolved.pricing.price_per_unit.feature_id %}
252
- {% assign _ppu_value = feature.value %}
253
- {% if _config_product and _ppu_value == nil %}
254
- {% for _lim in _config_product.limits %}{% if _lim[0] == feature.id %}{% assign _ppu_value = _lim[1] %}{% break %}{% endif %}{% endfor %}
255
- {% endif %}
256
- {% endif %}
257
- {% endfor %}
258
- {% endif %}
259
261
  <p class="pricing-plan-ppu">
260
- {% if _plan_monthly > 0 and _ppu_value and _ppu_value > 0 %}
261
- {% assign monthly_price_per_unit = _plan_monthly | times: 1.0 | divided_by: _ppu_value | round: 2 %}
262
- {% assign annual_monthly_price = _plan_annually | divided_by: 12.0 %}
263
- {% assign annual_price_per_unit = annual_monthly_price | divided_by: _ppu_value | round: 2 %}
262
+ {% if monthly_price_per_unit %}
264
263
  <span class="price-per-unit" data-monthly="${{ monthly_price_per_unit }}" data-annually="${{ annual_price_per_unit }}">${{ annual_price_per_unit }}</span> per {{ page.resolved.pricing.price_per_unit.label }}
265
264
  {% elsif _plan_monthly == 0 %}
266
265
  Perfect for trying out