@invisibleloop/pulse 0.12.3 → 0.14.2

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 (181) hide show
  1. package/.claude/commands/build-page.md +5 -1
  2. package/.claude/commands/verify.md +31 -17
  3. package/.claude/pulse-checklist.md +21 -1
  4. package/.github/workflows/publish.yml +1 -0
  5. package/.pulse-verified +1 -0
  6. package/CLAUDE.md +56 -15
  7. package/docs/.claude/commands/verify.md +4 -0
  8. package/docs/.claude/pulse-checklist.md +20 -2
  9. package/docs/public/.pulse-ui-version +1 -1
  10. package/docs/public/andypixel.css +237 -0
  11. package/docs/public/bookworms.css +422 -0
  12. package/docs/public/coutumes.css +584 -0
  13. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  14. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  15. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  16. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  17. package/docs/public/images/journey/178.jpg +0 -0
  18. package/docs/public/images/journey/29.jpg +0 -0
  19. package/docs/public/images/retrowheels/hero.jpg +0 -0
  20. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  21. package/docs/public/images/suzukibento/1015.jpg +0 -0
  22. package/docs/public/images/suzukibento/1025.jpg +0 -0
  23. package/docs/public/images/suzukibento/1043.jpg +0 -0
  24. package/docs/public/images/suzukibento/1059.jpg +0 -0
  25. package/docs/public/images/suzukibento/1062.jpg +0 -0
  26. package/docs/public/images/suzukibento/1084.jpg +0 -0
  27. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  28. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  29. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  30. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  31. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  32. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  33. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  34. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  35. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  36. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  37. package/docs/public/journey.css +528 -0
  38. package/docs/public/pulse-ui.css +1036 -22
  39. package/docs/public/retrowheels.css +402 -0
  40. package/docs/public/shop.css +235 -0
  41. package/docs/public/sparkys.css +349 -0
  42. package/docs/public/suzukibento.css +517 -0
  43. package/docs/public/swish.css +394 -0
  44. package/docs/server.js +19 -4
  45. package/docs/src/lib/nav.js +14 -3
  46. package/docs/src/pages/components/decorate.js +74 -0
  47. package/docs/src/pages/components/gallery.js +102 -0
  48. package/docs/src/pages/components/hero.js +1 -0
  49. package/docs/src/pages/components/marquee.js +76 -0
  50. package/docs/src/pages/components/photo-card.js +74 -0
  51. package/docs/src/pages/components/section.js +40 -1
  52. package/docs/src/pages/meta.js +26 -0
  53. package/docs/src/pages/templates/andypixel.js +260 -0
  54. package/docs/src/pages/templates/bookworms.js +227 -0
  55. package/docs/src/pages/templates/coutumes.js +226 -0
  56. package/docs/src/pages/templates/journey.js +186 -0
  57. package/docs/src/pages/templates/retrowheels.js +342 -0
  58. package/docs/src/pages/templates/shop.js +257 -0
  59. package/docs/src/pages/templates/sparkys.js +249 -0
  60. package/docs/src/pages/templates/suzukibento.js +200 -0
  61. package/docs/src/pages/templates/swish.js +313 -0
  62. package/docs/src/pages/templates.js +313 -2
  63. package/docs/src/pages/testing.js +2 -1
  64. package/examples/.claude/commands/pulse-build.md +7 -0
  65. package/examples/.claude/commands/pulse-dev.md +7 -0
  66. package/examples/.claude/commands/pulse-start.md +9 -0
  67. package/examples/.claude/commands/pulse-stop.md +5 -0
  68. package/examples/.claude/commands/verify.md +84 -0
  69. package/examples/.claude/coverage-check.js +127 -0
  70. package/examples/.claude/pulse-checklist.md +177 -0
  71. package/examples/andypixel.css +237 -0
  72. package/examples/andypixel.js +258 -0
  73. package/examples/bookworms.css +422 -0
  74. package/examples/bookworms.js +225 -0
  75. package/examples/contact.js +1 -0
  76. package/examples/counter.js +1 -0
  77. package/examples/dev.server.js +17 -0
  78. package/examples/journey.css +528 -0
  79. package/examples/journey.js +186 -0
  80. package/examples/pricing.js +1 -0
  81. package/examples/public/.pulse-ui-version +1 -0
  82. package/examples/public/pulse-ui.css +4031 -0
  83. package/examples/public/pulse-ui.js +257 -0
  84. package/examples/quiz.js +1 -0
  85. package/examples/retrowheels.css +402 -0
  86. package/examples/retrowheels.js +340 -0
  87. package/examples/shared.js +6 -0
  88. package/examples/suzukibento.css +513 -0
  89. package/examples/suzukibento.js +200 -0
  90. package/examples/swish.css +394 -0
  91. package/examples/swish.js +311 -0
  92. package/examples/todos.js +1 -0
  93. package/package.json +2 -3
  94. package/public/.pulse-ui-version +1 -1
  95. package/public/images/bookworms/hero-books.jpg +0 -0
  96. package/public/images/bookworms/how-we-read-now.jpg +0 -0
  97. package/public/images/bookworms/lantern-keepers.jpg +0 -0
  98. package/public/images/bookworms/provincial-bookseller.jpg +0 -0
  99. package/public/images/journey/178.jpg +0 -0
  100. package/public/images/journey/29.jpg +0 -0
  101. package/public/images/retrowheels/hero.jpg +0 -0
  102. package/public/images/shop/devbook-pro.jpg +0 -0
  103. package/public/images/suzukibento/1015.jpg +0 -0
  104. package/public/images/suzukibento/1025.jpg +0 -0
  105. package/public/images/suzukibento/1043.jpg +0 -0
  106. package/public/images/suzukibento/1059.jpg +0 -0
  107. package/public/images/suzukibento/1062.jpg +0 -0
  108. package/public/images/suzukibento/1084.jpg +0 -0
  109. package/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  110. package/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  111. package/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  112. package/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  113. package/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  114. package/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  115. package/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  116. package/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  117. package/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  118. package/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  119. package/public/pulse-ui.css +1080 -22
  120. package/scripts/build.js +72 -18
  121. package/src/agent/checklist.md +22 -2
  122. package/src/agent/commands/verify.md +4 -0
  123. package/src/agent/guide-components.md +146 -8
  124. package/src/agent/guide-design-gallery.md +288 -0
  125. package/src/agent/guide-design-references.md +172 -0
  126. package/src/agent/guide-explore.md +313 -0
  127. package/src/agent/guide-styles.md +212 -11
  128. package/src/agent/guide-templates.md +223 -10
  129. package/src/agent/guide.md +9 -4
  130. package/src/agent/identity.md +2 -1
  131. package/src/agent/skills/verify/SKILL.md +64 -28
  132. package/src/agent/workflow.md +247 -61
  133. package/src/cli/dev.js +39 -14
  134. package/src/cli/fmt.js +113 -0
  135. package/src/cli/index.js +60 -33
  136. package/src/cli/logger.js +145 -0
  137. package/src/cli/scaffold.js +57 -3
  138. package/src/html.js +6 -1
  139. package/src/mcp/server.js +2561 -99
  140. package/src/mcp/validate-worker.js +49 -5
  141. package/src/runtime/ssr.js +4 -1
  142. package/src/server/index.js +29 -5
  143. package/src/spec/schema.js +21 -0
  144. package/src/spec/schema.test.js +28 -0
  145. package/src/testing/html.js +130 -4
  146. package/src/testing/testing.test.js +58 -0
  147. package/src/ui/banner.js +24 -9
  148. package/src/ui/brand-statement.js +58 -0
  149. package/src/ui/button.js +13 -2
  150. package/src/ui/card.js +20 -1
  151. package/src/ui/colophon.js +29 -0
  152. package/src/ui/contact-row.js +41 -0
  153. package/src/ui/cta.js +18 -4
  154. package/src/ui/decorate.js +55 -0
  155. package/src/ui/display-heading.js +44 -0
  156. package/src/ui/drop-cap.js +29 -0
  157. package/src/ui/editorial-hero.js +63 -0
  158. package/src/ui/entry-list.js +36 -0
  159. package/src/ui/feature.js +20 -3
  160. package/src/ui/footer.js +78 -9
  161. package/src/ui/gallery.js +56 -0
  162. package/src/ui/hero.js +68 -18
  163. package/src/ui/image-pair.js +72 -0
  164. package/src/ui/index.js +20 -0
  165. package/src/ui/input.js +1 -0
  166. package/src/ui/marquee.js +48 -0
  167. package/src/ui/nav.js +19 -9
  168. package/src/ui/photo-card.js +44 -0
  169. package/src/ui/product-card.js +65 -0
  170. package/src/ui/pullquote.js +14 -3
  171. package/src/ui/search.js +1 -0
  172. package/src/ui/section-label.js +38 -0
  173. package/src/ui/section.js +1 -1
  174. package/src/ui/service-card.js +47 -0
  175. package/src/ui/stat-grid.js +40 -0
  176. package/src/ui/stat.js +16 -2
  177. package/src/ui/testimonial.js +17 -6
  178. package/src/ui/textarea.js +1 -0
  179. package/src/ui/top-strip.js +47 -0
  180. package/src/ui/trust-list.js +46 -0
  181. package/src/ui/ui.test.js +35 -0
@@ -0,0 +1,257 @@
1
+ /**
2
+ * pulse-ui.js — Vanilla JS for interactive Pulse UI components
3
+ *
4
+ * Handles:
5
+ * - Slider (.ui-slider — live fill + value output)
6
+ * - File Upload (.ui-upload — drag-and-drop, click-to-open)
7
+ * - Modal (data-modal-open, <dialog>, backdrop close)
8
+ * - Nav (.ui-nav — mobile burger menu)
9
+ * - Carousel (.ui-carousel — prev/next/dots)
10
+ *
11
+ * No dependencies. No build step required.
12
+ * CSP-safe: no inline handlers — all behaviour via event delegation.
13
+ * Include once per page: <script src="/pulse-ui.js"></script>
14
+ */
15
+
16
+ // ─── Slider ──────────────────────────────────────────────────────────────────
17
+ // Update --slider-fill on .ui-field wrapper + optional live value output.
18
+
19
+ document.addEventListener('input', (e) => {
20
+ const el = e.target
21
+ if (!el.classList.contains('ui-slider')) return
22
+ const field = el.closest('.ui-field')
23
+ if (!field) return
24
+ const pct = ((el.value - el.min) / (el.max - el.min) * 100).toFixed(2) + '%'
25
+ field.style.setProperty('--slider-fill', pct)
26
+ const out = field.querySelector('.ui-slider-output')
27
+ if (out) out.textContent = el.value
28
+ })
29
+
30
+ // ─── File Upload ─────────────────────────────────────────────────────────────
31
+ // Prevent browser navigating to dropped file anywhere on the page.
32
+ // Handle drag visual feedback + file assignment on drop.
33
+ // Handle click-to-open and keyboard activation.
34
+
35
+ document.addEventListener('dragover', (e) => {
36
+ e.preventDefault()
37
+ if (!(e.target instanceof Element)) return
38
+ const zone = e.target.closest('.ui-upload:not(.ui-upload--disabled)')
39
+ if (zone) zone.classList.add('ui-upload--active')
40
+ })
41
+
42
+ document.addEventListener('dragleave', (e) => {
43
+ if (!(e.target instanceof Element)) return
44
+ const zone = e.target.closest('.ui-upload')
45
+ if (zone && !zone.contains(e.relatedTarget)) {
46
+ zone.classList.remove('ui-upload--active')
47
+ }
48
+ })
49
+
50
+ document.addEventListener('drop', (e) => {
51
+ e.preventDefault()
52
+ if (!(e.target instanceof Element)) return
53
+ const zone = e.target.closest('.ui-upload:not(.ui-upload--disabled)')
54
+ if (!zone) return
55
+ zone.classList.remove('ui-upload--active')
56
+ const input = zone.querySelector('.ui-upload-input')
57
+ if (!input || !e.dataTransfer.files.length) return
58
+ const dt = new DataTransfer()
59
+ Array.from(e.dataTransfer.files).forEach(f => dt.items.add(f))
60
+ input.files = dt.files
61
+ input.dispatchEvent(new Event('change', { bubbles: true }))
62
+ })
63
+
64
+ // File Upload: show selected filename in zone after file chosen
65
+ document.addEventListener('change', (e) => {
66
+ const input = e.target
67
+ if (!input.classList.contains('ui-upload-input')) return
68
+ const zone = input.closest('.ui-upload')
69
+ if (!zone) return
70
+ const textEl = zone.querySelector('.ui-upload-text')
71
+ if (!textEl) return
72
+ if (input.files && input.files.length > 0) {
73
+ const names = Array.from(input.files).map(f => f.name).join(', ')
74
+ textEl.textContent = names
75
+ zone.classList.add('ui-upload--selected')
76
+ } else {
77
+ textEl.innerHTML = 'Drag &amp; drop or <span class="ui-upload-browse">browse</span>'
78
+ zone.classList.remove('ui-upload--selected')
79
+ }
80
+ })
81
+
82
+ // ─── Modal ──────────────────────────────────────────────────────────────────
83
+
84
+ document.addEventListener('click', (e) => {
85
+ // File upload: click zone → open file picker
86
+ // Skip synthetic clicks emitted by input.click() itself to avoid loops
87
+ if (!e.target.classList.contains('ui-upload-input')) {
88
+ const zone = e.target.closest('.ui-upload')
89
+ if (zone) {
90
+ const input = zone.querySelector('.ui-upload-input')
91
+ if (input && !input.disabled) { input.click(); return }
92
+ }
93
+ }
94
+
95
+ // Dialog open: data-dialog-open="dialogId" (first-class) or data-modal-open="dialogId" (compat)
96
+ const trigger = e.target.closest('[data-dialog-open],[data-modal-open]')
97
+ if (trigger) {
98
+ const id = trigger.dataset.dialogOpen ?? trigger.dataset.modalOpen
99
+ const dialog = document.getElementById(id)
100
+ if (dialog && typeof dialog.showModal === 'function') {
101
+ dialog.showModal()
102
+ }
103
+ return
104
+ }
105
+
106
+ // Dialog close: data-dialog-close (closes nearest ancestor <dialog>)
107
+ const closeTarget = e.target.closest('[data-dialog-close]')
108
+ if (closeTarget) {
109
+ const dialog = closeTarget.closest('dialog')
110
+ if (dialog) dialog.close()
111
+ return
112
+ }
113
+
114
+ // Backdrop close — click lands on <dialog> element itself
115
+ if (e.target.tagName === 'DIALOG') {
116
+ e.target.close()
117
+ }
118
+ })
119
+
120
+ // File upload: Enter / Space on focused zone → open picker
121
+ document.addEventListener('keydown', (e) => {
122
+ if (e.key !== 'Enter' && e.key !== ' ') return
123
+ const zone = e.target.closest('.ui-upload')
124
+ if (!zone) return
125
+ const input = zone.querySelector('.ui-upload-input')
126
+ if (input && !input.disabled) { e.preventDefault(); input.click() }
127
+ })
128
+
129
+ // ─── Nav (mobile burger) ────────────────────────────────────────────────────
130
+
131
+ function initNav(el) {
132
+ if (el.dataset.pulseNavInit) return
133
+ el.dataset.pulseNavInit = '1'
134
+
135
+ const burger = el.querySelector('.ui-nav-burger')
136
+ const mobile = el.querySelector('.ui-nav-mobile')
137
+
138
+ // Mobile burger
139
+ if (burger && mobile) {
140
+ const open = () => { el.classList.add('ui-nav--open'); burger.setAttribute('aria-expanded', 'true') }
141
+ const close = () => { el.classList.remove('ui-nav--open'); burger.setAttribute('aria-expanded', 'false') }
142
+ const toggle = () => el.classList.contains('ui-nav--open') ? close() : open()
143
+
144
+ burger.addEventListener('click', toggle)
145
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
146
+ document.addEventListener('click', (e) => { if (!el.contains(e.target)) close() })
147
+ mobile.querySelectorAll('.ui-nav-link').forEach(a => a.addEventListener('click', close))
148
+ }
149
+
150
+ // Mega nav dropdowns
151
+ el.querySelectorAll('.ui-nav-mega-wrap').forEach(wrap => {
152
+ const trigger = wrap.querySelector('.ui-nav-mega-trigger')
153
+ const panel = wrap.querySelector('.ui-nav-mega-panel')
154
+ if (!trigger || !panel) return
155
+
156
+ const openMega = () => { panel.hidden = false; wrap.classList.add('ui-nav-mega-wrap--open'); trigger.setAttribute('aria-expanded', 'true') }
157
+ const closeMega = () => { panel.hidden = true; wrap.classList.remove('ui-nav-mega-wrap--open'); trigger.setAttribute('aria-expanded', 'false') }
158
+ const toggleMega = () => panel.hidden ? openMega() : closeMega()
159
+
160
+ trigger.addEventListener('click', (e) => { e.stopPropagation(); toggleMega() })
161
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMega() })
162
+ document.addEventListener('click', (e) => { if (!wrap.contains(e.target)) closeMega() })
163
+ panel.querySelectorAll('.ui-nav-mega-item').forEach(a => a.addEventListener('click', closeMega))
164
+ })
165
+ }
166
+
167
+ function runNavs() { document.querySelectorAll('.ui-nav').forEach(initNav) }
168
+ document.readyState === 'loading'
169
+ ? document.addEventListener('DOMContentLoaded', runNavs)
170
+ : runNavs()
171
+ document.addEventListener('pulse:navigate', runNavs)
172
+
173
+ // ─── Carousel ───────────────────────────────────────────────────────────────
174
+
175
+ function initCarousel(el) {
176
+ if (el.dataset.pulseCarouselInit) return
177
+ el.dataset.pulseCarouselInit = '1'
178
+
179
+ const track = el.querySelector('.ui-carousel-track')
180
+ const prev = el.querySelector('.ui-carousel-prev')
181
+ const next = el.querySelector('.ui-carousel-next')
182
+ const dots = Array.from(el.querySelectorAll('.ui-carousel-dot'))
183
+ const slides = Array.from(el.querySelectorAll('.ui-carousel-slide'))
184
+
185
+ if (!track || slides.length === 0) return
186
+
187
+ let current = 0
188
+
189
+ const updateArrows = () => {
190
+ if (prev) prev.hidden = current === 0
191
+ if (next) next.hidden = current === slides.length - 1
192
+ }
193
+
194
+ const goTo = (i) => {
195
+ current = Math.max(0, Math.min(i, slides.length - 1))
196
+ track.scrollTo({ left: slides[current].offsetLeft, behavior: 'smooth' })
197
+ dots.forEach((d, j) => {
198
+ const active = j === current
199
+ d.classList.toggle('active', active)
200
+ d.setAttribute('aria-selected', String(active))
201
+ d.setAttribute('tabindex', active ? '0' : '-1')
202
+ })
203
+ updateArrows()
204
+ }
205
+
206
+ prev?.addEventListener('click', () => goTo(current - 1))
207
+ next?.addEventListener('click', () => goTo(current + 1))
208
+ dots.forEach((d, i) => d.addEventListener('click', () => goTo(i)))
209
+
210
+ // Keyboard navigation — roving tabindex for tablist
211
+ el.addEventListener('keydown', (e) => {
212
+ if (!e.target.classList.contains('ui-carousel-dot')) return
213
+ let next = null
214
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
215
+ e.preventDefault()
216
+ next = current > 0 ? current - 1 : slides.length - 1
217
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
218
+ e.preventDefault()
219
+ next = current < slides.length - 1 ? current + 1 : 0
220
+ } else if (e.key === 'Home') {
221
+ e.preventDefault()
222
+ next = 0
223
+ } else if (e.key === 'End') {
224
+ e.preventDefault()
225
+ next = slides.length - 1
226
+ }
227
+ if (next !== null) {
228
+ goTo(next)
229
+ dots[next]?.focus()
230
+ }
231
+ })
232
+
233
+ // Sync dot state and arrows when user swipes / scrolls
234
+ track.addEventListener('scroll', () => {
235
+ const trackLeft = track.getBoundingClientRect().left
236
+ const idx = slides.findIndex((s) => Math.abs(s.getBoundingClientRect().left - trackLeft) < 10)
237
+ if (idx !== -1 && idx !== current) {
238
+ current = idx
239
+ dots.forEach((d, j) => {
240
+ const active = j === current
241
+ d.classList.toggle('active', active)
242
+ d.setAttribute('aria-selected', String(active))
243
+ d.setAttribute('tabindex', active ? '0' : '-1')
244
+ })
245
+ updateArrows()
246
+ }
247
+ }, { passive: true })
248
+ }
249
+
250
+ // Initialise all carousels on the page
251
+ const run = () => document.querySelectorAll('.ui-carousel').forEach(initCarousel)
252
+ document.readyState === 'loading'
253
+ ? document.addEventListener('DOMContentLoaded', run)
254
+ : run()
255
+
256
+ // Re-initialise after Pulse client-side navigation swaps the DOM
257
+ document.addEventListener('pulse:navigate', run)
package/examples/quiz.js CHANGED
@@ -67,6 +67,7 @@ export default {
67
67
  title: 'Quiz — Pulse',
68
68
  description: 'A multiple-choice quiz built with Pulse. Demonstrates multi-step state machine and computed views.',
69
69
  styles: ['/pulse-ui.css'],
70
+ theme: 'dark',
70
71
  },
71
72
 
72
73
  state: {
@@ -0,0 +1,402 @@
1
+ /*
2
+ * retrowheels.css — Retro Wheels Co. bicycle shop example
3
+ *
4
+ * 1970s enamel-sign aesthetic: cream paper, burnt orange, mustard yellow,
5
+ * forest green, oxidised navy. Rockwell slab serif + Courier New monospace.
6
+ * All system fonts — zero external HTTP requests.
7
+ *
8
+ * Split into two logical sections:
9
+ * 1. Token overrides — hex values, palette definitions
10
+ * 2. Component styles — layout, typography, custom elements (var() only)
11
+ */
12
+
13
+ /* ==========================================================================
14
+ 1. Token overrides (hex values live here)
15
+ ========================================================================== */
16
+
17
+ /* Named palette — referenced throughout this file via var() */
18
+ :root {
19
+ --rw-orange: #c8431f;
20
+ --rw-orange-dark: #a8351a;
21
+ --rw-cream: #f4e9d3;
22
+ --rw-cream-deep: #ead9b8;
23
+ --rw-navy: #1f2a3a;
24
+ --rw-mustard: #d9a441;
25
+ --rw-mustard-dark: #8a6418;
26
+ --rw-green: #2f5d3a;
27
+ --rw-green-dark: #1f3f27;
28
+ --rw-ink: #2b2018;
29
+ }
30
+
31
+ /* System slab-serif stack for headings — no Google Fonts needed */
32
+ :root {
33
+ --font: Georgia, 'Bookman Old Style', serif;
34
+ --font-display: 'Rockwell', 'Bookman Old Style', Georgia, serif;
35
+ }
36
+
37
+ [data-theme="light"] {
38
+ --ui-bg: #f4e9d3;
39
+ --ui-surface: #fbf3df;
40
+ --ui-surface-2: #ead9b8;
41
+ --ui-border: #c9b890;
42
+ --ui-text: #2b2018;
43
+ --ui-muted: #6b5d48;
44
+ --ui-accent: #a8351a;
45
+ --ui-accent-hover: #8a2912;
46
+ --ui-accent-dim: rgba(168, 53, 26, 0.12);
47
+ --ui-accent-text: #fbf3df;
48
+ }
49
+
50
+ [data-theme="light"] .ui-btn--primary {
51
+ color: #fbf3df;
52
+ }
53
+
54
+ /* ==========================================================================
55
+ 2. Component styles (var() only — no hex values below this line)
56
+ ========================================================================== */
57
+
58
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
59
+
60
+ body {
61
+ font-family: var(--ui-font);
62
+ line-height: 1.6;
63
+ color: var(--ui-text);
64
+ background: var(--ui-bg);
65
+ }
66
+
67
+ /* Slab-serif for all headings and named component titles */
68
+ h1, h2, h3, h4, h5, h6,
69
+ .ui-hero-title, .ui-section-title, .ui-card-title,
70
+ .ui-cta-title, .ui-feature-title, .ui-stat-value {
71
+ font-family: var(--ui-font-display);
72
+ letter-spacing: -0.01em;
73
+ }
74
+
75
+ /* --------------------------------------------------------------------------
76
+ Brand logo wordmark
77
+ -------------------------------------------------------------------------- */
78
+ .rw-logo-text {
79
+ font-family: var(--ui-font-display);
80
+ font-weight: 900;
81
+ letter-spacing: -0.01em;
82
+ font-size: 1.1rem;
83
+ }
84
+
85
+ /* --------------------------------------------------------------------------
86
+ Skip link
87
+ -------------------------------------------------------------------------- */
88
+ .rw-skip-link {
89
+ position: absolute;
90
+ left: -9999px;
91
+ top: 0;
92
+ }
93
+
94
+ .rw-skip-link:focus {
95
+ left: var(--ui-space-2);
96
+ top: var(--ui-space-2);
97
+ background: var(--rw-cream);
98
+ color: var(--rw-navy);
99
+ padding: var(--ui-space-2) var(--ui-space-3);
100
+ z-index: 1000;
101
+ }
102
+
103
+ /* --------------------------------------------------------------------------
104
+ Eyebrow labels
105
+ -------------------------------------------------------------------------- */
106
+ .rw-eyebrow {
107
+ font-family: 'Courier New', monospace;
108
+ letter-spacing: 0.25em;
109
+ text-transform: uppercase;
110
+ font-size: var(--ui-text-xs);
111
+ color: var(--rw-orange-dark);
112
+ }
113
+
114
+ .rw-eyebrow--on-orange {
115
+ color: var(--rw-cream);
116
+ }
117
+
118
+ /* --------------------------------------------------------------------------
119
+ Decorative rule
120
+ -------------------------------------------------------------------------- */
121
+ .rw-rule {
122
+ border: 0;
123
+ border-top: 3px double var(--ui-border);
124
+ margin: var(--ui-space-8) 0;
125
+ }
126
+
127
+ /* --------------------------------------------------------------------------
128
+ Full-bleed hero
129
+ -------------------------------------------------------------------------- */
130
+ .rw-hero {
131
+ position: relative;
132
+ min-height: 70vh;
133
+ background:
134
+ linear-gradient(180deg, rgba(31, 42, 58, 0.55) 0%, rgba(31, 42, 58, 0.85) 100%),
135
+ url('/images/retrowheels/hero.jpg') center / cover no-repeat;
136
+ color: var(--rw-cream);
137
+ padding: var(--ui-space-24) var(--ui-space-6);
138
+ display: flex;
139
+ align-items: flex-end;
140
+ }
141
+
142
+ .rw-hero-inner {
143
+ max-width: 64rem;
144
+ margin: 0 auto;
145
+ width: 100%;
146
+ }
147
+
148
+ .rw-hero-eyebrow {
149
+ font-family: 'Courier New', monospace;
150
+ letter-spacing: 0.3em;
151
+ text-transform: uppercase;
152
+ font-size: var(--ui-text-sm);
153
+ color: var(--rw-mustard);
154
+ display: inline-block;
155
+ padding: var(--ui-space-1) var(--ui-space-3);
156
+ border: 2px solid var(--rw-mustard);
157
+ margin-bottom: var(--ui-space-6);
158
+ }
159
+
160
+ .rw-hero-title {
161
+ font-family: var(--ui-font-display);
162
+ font-weight: 900;
163
+ font-size: clamp(2.5rem, 7vw, 5.5rem);
164
+ line-height: 1.05;
165
+ letter-spacing: -0.02em;
166
+ margin-bottom: var(--ui-space-5);
167
+ text-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
168
+ }
169
+
170
+ .rw-hero-title em {
171
+ font-style: italic;
172
+ color: var(--rw-mustard);
173
+ }
174
+
175
+ .rw-hero-sub {
176
+ font-size: var(--ui-text-xl);
177
+ max-width: 38rem;
178
+ margin-bottom: var(--ui-space-6);
179
+ color: var(--rw-cream);
180
+ opacity: 0.95;
181
+ }
182
+
183
+ .rw-hero-actions {
184
+ display: flex;
185
+ gap: var(--ui-space-3);
186
+ flex-wrap: wrap;
187
+ }
188
+
189
+ .rw-hero-image-credit {
190
+ position: absolute;
191
+ bottom: var(--ui-space-2);
192
+ right: var(--ui-space-3);
193
+ font-size: var(--ui-text-xs);
194
+ opacity: 0.6;
195
+ font-family: 'Courier New', monospace;
196
+ }
197
+
198
+ /* --------------------------------------------------------------------------
199
+ Stat strip (inverted navy bar)
200
+ -------------------------------------------------------------------------- */
201
+ .rw-stat-strip {
202
+ background: var(--rw-navy);
203
+ color: var(--rw-cream);
204
+ padding: var(--ui-space-8) var(--ui-space-6);
205
+ }
206
+
207
+ .rw-stat-strip .ui-stat-label {
208
+ color: var(--rw-mustard);
209
+ font-family: 'Courier New', monospace;
210
+ letter-spacing: 0.2em;
211
+ text-transform: uppercase;
212
+ font-size: var(--ui-text-xs);
213
+ }
214
+
215
+ .rw-stat-strip .ui-stat-value {
216
+ color: var(--rw-cream);
217
+ font-family: var(--ui-font-display);
218
+ font-size: var(--ui-text-4xl);
219
+ }
220
+
221
+ .rw-stat-strip-inner {
222
+ max-width: 64rem;
223
+ margin: 0 auto;
224
+ display: grid;
225
+ grid-template-columns: repeat(4, 1fr);
226
+ gap: var(--ui-space-6);
227
+ text-align: center;
228
+ }
229
+
230
+ @media (max-width: 640px) {
231
+ .rw-stat-strip-inner {
232
+ grid-template-columns: repeat(2, 1fr);
233
+ }
234
+ }
235
+
236
+ /* --------------------------------------------------------------------------
237
+ Editorial prose section (drop cap on first paragraph)
238
+ -------------------------------------------------------------------------- */
239
+ .rw-editorial {
240
+ max-width: 42rem;
241
+ margin: 0 auto;
242
+ padding: var(--ui-space-16) var(--ui-space-6);
243
+ }
244
+
245
+ .rw-editorial p {
246
+ font-size: var(--ui-text-lg);
247
+ margin-bottom: var(--ui-space-5);
248
+ }
249
+
250
+ .rw-editorial hr + p::first-letter {
251
+ font-family: var(--ui-font-display);
252
+ font-size: 3.5rem;
253
+ font-weight: 900;
254
+ float: left;
255
+ line-height: 0.9;
256
+ padding: 0.25rem var(--ui-space-2) 0 0;
257
+ color: var(--rw-orange-dark);
258
+ }
259
+
260
+ /* --------------------------------------------------------------------------
261
+ Section template
262
+ -------------------------------------------------------------------------- */
263
+ .rw-section {
264
+ padding: var(--ui-space-16) var(--ui-space-6);
265
+ }
266
+
267
+ .rw-section--alt {
268
+ background: var(--rw-cream-deep);
269
+ }
270
+
271
+ .rw-section-inner {
272
+ max-width: 64rem;
273
+ margin: 0 auto;
274
+ }
275
+
276
+ .rw-section-title {
277
+ font-family: var(--ui-font-display);
278
+ font-size: var(--ui-text-4xl);
279
+ font-weight: 900;
280
+ margin-bottom: var(--ui-space-2);
281
+ letter-spacing: -0.01em;
282
+ }
283
+
284
+ .rw-section-deck {
285
+ color: var(--ui-muted);
286
+ font-size: var(--ui-text-lg);
287
+ margin-bottom: var(--ui-space-10);
288
+ max-width: 36rem;
289
+ }
290
+
291
+ /* --------------------------------------------------------------------------
292
+ Services grid
293
+ -------------------------------------------------------------------------- */
294
+ .rw-service-grid {
295
+ display: grid;
296
+ grid-template-columns: repeat(3, 1fr);
297
+ gap: var(--ui-space-6);
298
+ }
299
+
300
+ @media (max-width: 900px) { .rw-service-grid { grid-template-columns: repeat(2, 1fr); } }
301
+ @media (max-width: 600px) { .rw-service-grid { grid-template-columns: 1fr; } }
302
+
303
+ .rw-service {
304
+ background: var(--ui-surface);
305
+ border: 2px solid var(--ui-border);
306
+ padding: var(--ui-space-6);
307
+ position: relative;
308
+ }
309
+
310
+ .rw-service-num {
311
+ font-family: 'Courier New', monospace;
312
+ font-size: var(--ui-text-xs);
313
+ letter-spacing: 0.2em;
314
+ color: var(--rw-orange-dark);
315
+ margin-bottom: var(--ui-space-3);
316
+ }
317
+
318
+ .rw-service h3 {
319
+ font-family: var(--ui-font-display);
320
+ font-size: var(--ui-text-xl);
321
+ font-weight: 900;
322
+ margin-bottom: var(--ui-space-2);
323
+ }
324
+
325
+ .rw-service p {
326
+ color: var(--ui-muted);
327
+ margin-bottom: var(--ui-space-4);
328
+ }
329
+
330
+ .rw-service-price {
331
+ font-family: 'Courier New', monospace;
332
+ font-size: var(--ui-text-sm);
333
+ color: var(--rw-green-dark);
334
+ border-top: 1px dashed var(--ui-border);
335
+ padding-top: var(--ui-space-3);
336
+ }
337
+
338
+ /* --------------------------------------------------------------------------
339
+ Visit section — hours + booking card
340
+ -------------------------------------------------------------------------- */
341
+ .rw-visit-grid {
342
+ display: grid;
343
+ grid-template-columns: 1fr 1fr;
344
+ gap: var(--ui-space-12);
345
+ align-items: start;
346
+ }
347
+
348
+ @media (max-width: 720px) {
349
+ .rw-visit-grid { grid-template-columns: 1fr; }
350
+ }
351
+
352
+ .rw-hours-heading {
353
+ font-family: var(--ui-font-display);
354
+ font-size: var(--ui-text-xl);
355
+ font-weight: 900;
356
+ margin-bottom: var(--ui-space-3);
357
+ }
358
+
359
+ .rw-hours {
360
+ font-family: 'Courier New', monospace;
361
+ font-size: var(--ui-text-base);
362
+ list-style: none;
363
+ }
364
+
365
+ .rw-hours-row {
366
+ display: flex;
367
+ justify-content: space-between;
368
+ padding: var(--ui-space-2) 0;
369
+ border-bottom: 1px dashed var(--ui-border);
370
+ }
371
+
372
+ .rw-hours li:last-child { border-bottom: 0; }
373
+
374
+ .rw-closed { color: var(--rw-orange-dark); }
375
+
376
+ .rw-address {
377
+ margin-top: var(--ui-space-6);
378
+ line-height: 1.7;
379
+ }
380
+
381
+ /* --------------------------------------------------------------------------
382
+ Saturday ride banner (full-bleed burnt orange)
383
+ -------------------------------------------------------------------------- */
384
+ .rw-ride-banner {
385
+ background: var(--rw-orange-dark);
386
+ color: var(--rw-cream);
387
+ padding: var(--ui-space-16) var(--ui-space-6);
388
+ text-align: center;
389
+ }
390
+
391
+ .rw-ride-banner h2 {
392
+ color: var(--rw-cream);
393
+ font-size: clamp(2rem, 5vw, 3.5rem);
394
+ font-weight: 900;
395
+ margin-bottom: var(--ui-space-3);
396
+ }
397
+
398
+ .rw-ride-banner p {
399
+ font-size: var(--ui-text-lg);
400
+ max-width: 40rem;
401
+ margin: 0 auto var(--ui-space-6);
402
+ }