@invisibleloop/pulse 0.14.2 → 0.15.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.
@@ -87,13 +87,20 @@ export default {
87
87
  <p>Together these replace the need to paste documentation into a system prompt or rely on the agent's training data to know how Pulse works.</p>
88
88
 
89
89
  ${section('resources', 'Resources: what the agent knows')}
90
- <p>The MCP server exposes two resources the agent reads before doing any work:</p>
91
- <ul>
92
- <li><strong><code>pulse://guide</code></strong> — the complete framework reference, split into focused sections so each fits comfortably in a single read. The agent fetches whichever sections are relevant to the task at hand.</li>
93
- <li><strong><code>pulse://persona</code></strong> — the quality bar the agent holds itself to: always write correct SSR, always handle errors, never skip empty states, never hardcode colours, never use <code>data-event</code> on text inputs. The persona defines what "done" means.</li>
94
- </ul>
90
+ <p>The MCP server exposes read-only documents the agent fetches at any time. There are two top-level resources and a set of focused guide sections:</p>
91
+
92
+ <dl class="definition-list">
93
+ <dt><code>pulse://quickstart</code></dt>
94
+ <dd>Workflow phases, spec skeleton, component rules, and theming essentials combined into a single fetch. Used for targeted edits, bug fixes, or simple one-shot page builds — avoids loading the full guide for small tasks.</dd>
95
+
96
+ <dt><code>pulse://workflow</code></dt>
97
+ <dd>The full build workflow — every phase, pass gate, and ordering rule. The agent reads this before starting any new page or project.</dd>
98
+
99
+ <dt><code>pulse://persona</code></dt>
100
+ <dd>The quality bar the agent holds itself to: always write correct SSR, always handle errors, never skip empty states, never hardcode colours, never use <code>data-event</code> on text inputs. The persona defines what "done" means.</dd>
101
+ </dl>
95
102
 
96
- <p>The guide sections are:</p>
103
+ <p>The guide is split into topic sections the agent fetches as needed:</p>
97
104
  <dl class="definition-list">
98
105
  <dt><code>pulse://guide/spec</code></dt>
99
106
  <dd>Spec format — state, mutations, actions, streaming SSR, validation, key rules, and form layout.</dd>
@@ -112,12 +119,51 @@ export default {
112
119
 
113
120
  <dt><code>pulse://guide/examples</code></dt>
114
121
  <dd>Complete working page examples covering common patterns.</dd>
122
+
123
+ <dt><code>pulse://guide/templates</code></dt>
124
+ <dd>Pre-built landing page templates, adaptation rules, and theme CSS patterns. Fetched when building a new branded site or landing page.</dd>
125
+
126
+ <dt><code>pulse://guide/design-references</code></dt>
127
+ <dd>Twelve design directions with vibes, component combinations, palette patterns, and signature moves. Fetched during intake to pick the right aesthetic rather than defaulting to a generic SaaS look.</dd>
128
+
129
+ <dt><code>pulse://guide/design-gallery</code></dt>
130
+ <dd>All templates with visual descriptions and key components. Includes critical prop-name references and component recipes for cards, stat strips, and forms. Fetched when adapting a template or combining components.</dd>
131
+
132
+ <dt><code>pulse://guide/explore</code></dt>
133
+ <dd>Zone-based layout thinking, seven structural gestures, raw HTML patterns without components, and an anti-pattern checklist. Fetched when a distinctive or unusual layout is needed.</dd>
115
134
  </dl>
116
135
 
117
136
  ${callout('note', 'The guide is authoritative. When there is a question about how something should be structured — where state lives, how validation is wired, how streaming works — the guide answers it. The agent does not guess.')}
118
137
 
119
138
  ${section('tools', 'Tools: what the agent can do')}
120
- <p>The MCP server provides tools across four categories:</p>
139
+ <p>The MCP server provides tools across five categories:</p>
140
+
141
+ <h3 class="doc-h3">Design &amp; intake</h3>
142
+ <dl class="definition-list">
143
+ <dt><code>pulse_extract_inspiration</code></dt>
144
+ <dd>Extracts a structured design brief from a URL or image shared as inspiration. Called before <code>pulse_intake</code> when the user says "I like the look of X" — it tells the agent exactly what to observe and capture.</dd>
145
+
146
+ <dt><code>pulse_intake</code></dt>
147
+ <dd>Captures the product name, pitch, features, palette, vibe, and anti-style before scaffolding any new page or branded template. Returns a structured brief with copy-ready content, early contrast warnings, and component suggestions. Always runs before <code>pulse_sketch</code>.</dd>
148
+
149
+ <dt><code>pulse_sketch</code></dt>
150
+ <dd>Generates three structurally distinct layout directions (full-bleed, asymmetric split, editorial flow, etc.) before any code is written. Called after <code>pulse_intake</code> — prevents every project from defaulting to the same centred-hero layout.</dd>
151
+
152
+ <dt><code>pulse_intent</code></dt>
153
+ <dd>Intent engine — describe what you want to build and get back a matched archetype, recommended components, a ready-to-adapt spec scaffold, and which guide sections to read first. An alternative entry point to <code>pulse://workflow</code> for targeted tasks.</dd>
154
+
155
+ <dt><code>pulse_design_review</code></dt>
156
+ <dd>Reviews the built page's visual design against the original product brief from <code>pulse_intake</code>. Checks whether the design looks and feels appropriate for the product, audience, and vibe. Runs after screenshot, before Lighthouse.</dd>
157
+
158
+ <dt><code>pulse_layout_review</code></dt>
159
+ <dd>Multi-viewport layout review — checks for overflow, collapsed sections, and broken images at different screen widths. Runs after design approval but before Lighthouse, catching layout issues that Lighthouse doesn't flag.</dd>
160
+
161
+ <dt><code>pulse_tokens</code></dt>
162
+ <dd>Lists all available <code>--ui-*</code> CSS custom property tokens from the installed <code>pulse-ui.css</code>. Used before writing theme overrides to avoid guessing token names.</dd>
163
+
164
+ <dt><code>pulse_check_contrast</code></dt>
165
+ <dd>Static WCAG contrast checker. Extracts colour variable definitions from a theme file and checks common foreground/background pairings for WCAG AA compliance. Run immediately after writing a theme file to catch palette mistakes before Lighthouse.</dd>
166
+ </dl>
121
167
 
122
168
  <h3 class="doc-h3">Scaffolding</h3>
123
169
  <dl class="definition-list">
@@ -136,11 +182,17 @@ export default {
136
182
 
137
183
  <h3 class="doc-h3">Inspection</h3>
138
184
  <dl class="definition-list">
185
+ <dt><code>pulse_status</code></dt>
186
+ <dd>Project health snapshot — pages, routes, server status, last build. Called at the start of a session to orient quickly without reading files individually.</dd>
187
+
139
188
  <dt><code>pulse_list_structure</code></dt>
140
189
  <dd>Lists every page, component, and store that already exists in the project. The agent calls this before creating anything — to avoid duplication and to understand what the codebase already provides.</dd>
141
190
 
142
191
  <dt><code>pulse_fetch_page</code></dt>
143
192
  <dd>Reads the full source of an existing spec file. Used when the agent needs to understand an existing page before editing it, or when a review of the current state is needed.</dd>
193
+
194
+ <dt><code>pulse_list_icons</code></dt>
195
+ <dd>Lists all available Pulse UI icon names, grouped by category. Called before importing icons — the agent never guesses a name.</dd>
144
196
  </dl>
145
197
 
146
198
  <h3 class="doc-h3">Validation &amp; review</h3>
@@ -148,26 +200,29 @@ export default {
148
200
  <dt><code>pulse_validate</code></dt>
149
201
  <dd>Validates a spec against the Pulse schema. Returns errors (which block progress) and warnings (which must also be resolved). The agent calls this after writing or editing every spec file.</dd>
150
202
 
203
+ <dt><code>pulse_suggest</code></dt>
204
+ <dd>Draft-mode feedback — paste a partial or complete spec and get constructive suggestions before running the hard validator. Unlike <code>pulse_validate</code>, it is collaborative rather than a gate: it notices patterns, spots likely omissions, and offers ideas. Used mid-build for a second opinion.</dd>
205
+
151
206
  <dt><code>pulse_review</code></dt>
152
207
  <dd>Switches the agent into reviewer mode. Returns the spec source, the rendered HTML output, and a structured checklist covering accessibility, empty states, error handling, component usage, security, and correctness. The agent reads its own output critically and fixes every issue before continuing.</dd>
208
+
209
+ <dt><code>pulse_run_tests</code></dt>
210
+ <dd>Runs the project test suite (<code>npm test</code>) and returns the full output. Called after writing or editing specs to verify nothing is broken.</dd>
153
211
  </dl>
154
212
 
155
- <h3 class="doc-h3">Server</h3>
213
+ <h3 class="doc-h3">Server &amp; maintenance</h3>
156
214
  <dl class="definition-list">
157
215
  <dt><code>pulse_restart_server</code></dt>
158
216
  <dd>Restarts the Pulse dev server. Called after structural changes — adding a new page, modifying the server entry — to reload all specs and re-register routes.</dd>
159
217
 
160
218
  <dt><code>pulse_build</code></dt>
161
219
  <dd>Builds the project for production. Bundles specs, hashes assets, and writes the manifest. Called when the agent needs to verify the production build or prepare a release.</dd>
162
- </dl>
163
220
 
164
- <h3 class="doc-h3">Maintenance</h3>
165
- <dl class="definition-list">
166
221
  <dt><code>pulse_check_version</code></dt>
167
222
  <dd>Reports the installed Pulse version and whether an update is available.</dd>
168
223
 
169
224
  <dt><code>pulse_update</code></dt>
170
- <dd>Updates the Pulse package to the latest version.</dd>
225
+ <dd>Updates the Pulse package to the latest version and copies the latest UI assets and agent files into the project.</dd>
171
226
  </dl>
172
227
 
173
228
  ${section('flow', 'What happens when you ask for something')}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.14.2",
3
+ "version": "0.15.1",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.14.2
1
+ 0.15.1
package/scripts/build.js CHANGED
@@ -19,7 +19,7 @@ import path from 'path'
19
19
  import { createHash } from 'crypto'
20
20
  import { discoverPages } from '../src/cli/discover.js'
21
21
  import { renderToString } from '../src/runtime/ssr.js'
22
- import { c, header, table, ok, fail, info, elapsed, icon } from '../src/cli/fmt.js'
22
+ import { c, header, table, ok, fail, info, warn, elapsed, icon } from '../src/cli/fmt.js'
23
23
 
24
24
  // ---------------------------------------------------------------------------
25
25
  // Server-only property stripping
@@ -380,6 +380,23 @@ const ROOT = rootArg !== -1
380
380
  const OUT_DIR = path.join(ROOT, 'public', 'dist')
381
381
  const TMP_DIR = path.join(ROOT, '.pulse-build')
382
382
 
383
+ // ---------------------------------------------------------------------------
384
+ // Load pulse.config.js (optional)
385
+ // ---------------------------------------------------------------------------
386
+
387
+ let _projectConfig = {}
388
+ const _configPath = path.join(ROOT, 'pulse.config.js')
389
+ if (fs.existsSync(_configPath)) {
390
+ try { _projectConfig = (await import(_configPath)).default ?? {} } catch { /* ignore */ }
391
+ }
392
+
393
+ // css.safelist — array of class name strings or RegExp patterns to always keep
394
+ // even if the purger would otherwise remove them.
395
+ // Example in pulse.config.js:
396
+ // export default { css: { safelist: ['my-class', /^js-/] } }
397
+ const CSS_SAFELIST = (_projectConfig.css?.safelist ?? [])
398
+ .map(entry => typeof entry === 'string' ? entry : entry)
399
+
383
400
  // ---------------------------------------------------------------------------
384
401
  // Discover pages
385
402
  // ---------------------------------------------------------------------------
@@ -516,7 +533,7 @@ fs.rmSync(TMP_DIR, { recursive: true })
516
533
  // CSS Purge — strip unused styles, write content-hashed CSS bundles
517
534
  // ---------------------------------------------------------------------------
518
535
 
519
- await purgeCssStep(pages, manifest, ROOT, OUT_DIR)
536
+ const cssPurgeStats = await purgeCssStep(pages, manifest, ROOT, OUT_DIR)
520
537
 
521
538
  // ---------------------------------------------------------------------------
522
539
  // JS static assets — minify + hash any .js files directly in public/
@@ -556,13 +573,17 @@ await jsAssetsStep(ROOT, OUT_DIR, manifest)
556
573
  c.dim(bundle.replace('/dist/', '')),
557
574
  `${sizeKb} kB`,
558
575
  c.dim('js'),
576
+ '',
559
577
  ])
560
578
  } else if (ext === '.css') {
579
+ const pct = cssPurgeStats?.[src]
580
+ const pctLabel = pct != null ? c.dim(`−${pct}%`) : ''
561
581
  rows.push([
562
582
  c.dim(src),
563
583
  c.dim(bundle.replace('/dist/', '')),
564
584
  `${sizeKb} kB`,
565
585
  c.dim('css'),
586
+ pctLabel,
566
587
  ])
567
588
  }
568
589
  }
@@ -577,9 +598,9 @@ await jsAssetsStep(ROOT, OUT_DIR, manifest)
577
598
  })
578
599
 
579
600
  console.log('\n' + table(
580
- ['Route / Asset', 'Bundle', 'Size', 'Type'],
601
+ ['Route / Asset', 'Bundle', 'Size', 'Type', 'Purged'],
581
602
  rows,
582
- { align: ['left', 'left', 'right', 'left'] }
603
+ { align: ['left', 'left', 'right', 'left', 'right'] }
583
604
  ))
584
605
 
585
606
  const totalMs = Date.now() - BUILD_START
@@ -599,9 +620,28 @@ async function jsAssetsStep(root, outDir, manifest) {
599
620
 
600
621
  console.log(info('Hashing static JS assets…'))
601
622
 
623
+ // Collect fetch/XHR origins used in static JS for CSP connect-src checking
624
+ const cspConnectSrc = new Set([
625
+ ...(_projectConfig.csp?.['connect-src'] ?? []),
626
+ 'self',
627
+ ])
628
+
602
629
  for (const file of jsFiles) {
603
630
  const srcPath = path.join(publicDir, file)
604
631
  const source = fs.readFileSync(srcPath, 'utf8')
632
+
633
+ // Warn if the file contains fetch/XMLHttpRequest calls to origins not in
634
+ // the project's CSP connect-src — these will silently fail in production.
635
+ const fetchUrls = [...source.matchAll(/fetch\s*\(\s*['"`](https?:\/\/[^'"`]+)['"`]/g)].map(m => m[1])
636
+ for (const url of fetchUrls) {
637
+ try {
638
+ const origin = new URL(url).origin
639
+ if (!cspConnectSrc.has(origin) && !cspConnectSrc.has('*')) {
640
+ console.log(warn(`${file}: fetch to ${origin} may be blocked — add to csp.connect-src in pulse.config.js`))
641
+ }
642
+ } catch { /* ignore malformed URLs */ }
643
+ }
644
+
605
645
  const { code } = await esbuild.transform(source, { minify: true, target: 'es2018' })
606
646
  const hash = createHash('sha256').update(code).digest('hex').slice(0, 8)
607
647
  const name = path.basename(file, '.js')
@@ -610,8 +650,6 @@ async function jsAssetsStep(root, outDir, manifest) {
610
650
  fs.writeFileSync(path.join(outDir, outName), code)
611
651
  manifest[`/${file}`] = `/dist/${outName}`
612
652
 
613
- const origKb = (Buffer.byteLength(source) / 1024).toFixed(1)
614
- const minKb = (Buffer.byteLength(code) / 1024).toFixed(1)
615
653
  // silent — summary table covers this
616
654
  }
617
655
 
@@ -629,6 +667,7 @@ async function purgeCssStep(pages, manifest, root, outDir) {
629
667
  const htmlContents = []
630
668
  const jsContents = []
631
669
  const cssFiles = new Set()
670
+ const cssStats = {}
632
671
 
633
672
  for (const { filePath } of pages) {
634
673
  try {
@@ -667,6 +706,16 @@ async function purgeCssStep(pages, manifest, root, outDir) {
667
706
  }
668
707
  }
669
708
 
709
+ // Scan public/*.js static assets — these often contain classList.toggle() or
710
+ // classList.add() calls that reference CSS classes not present in any page spec,
711
+ // which would otherwise be incorrectly purged from the built CSS.
712
+ const publicDir = path.join(root, 'public')
713
+ if (fs.existsSync(publicDir)) {
714
+ for (const f of fs.readdirSync(publicDir).filter(f => f.endsWith('.js'))) {
715
+ try { jsContents.push(fs.readFileSync(path.join(publicDir, f), 'utf8')) } catch {}
716
+ }
717
+ }
718
+
670
719
  // Scan pulse UI package source — class names generated by UI components
671
720
  // (breadcrumbs, avatar, container, etc.) only appear in the package source,
672
721
  // not in the project's own pages, so they'd otherwise be purged.
@@ -687,10 +736,10 @@ async function purgeCssStep(pages, manifest, root, outDir) {
687
736
 
688
737
  if (cssFiles.size === 0) {
689
738
  console.log(info('No CSS files referenced — skipping.'))
690
- return
739
+ return cssStats
691
740
  }
692
741
 
693
- const usedClasses = extractUsedClasses(htmlContents, jsContents)
742
+ const usedClasses = extractUsedClasses(htmlContents, jsContents, CSS_SAFELIST)
694
743
 
695
744
  for (const cssHref of cssFiles) {
696
745
  // CSS path is relative to public/ (e.g. '/pulse-ui.css' → public/pulse-ui.css)
@@ -698,6 +747,21 @@ async function purgeCssStep(pages, manifest, root, outDir) {
698
747
  if (!fs.existsSync(cssPath)) continue
699
748
 
700
749
  const original = fs.readFileSync(cssPath, 'utf8')
750
+
751
+ // Warn about classes defined in this CSS file that weren't found in any
752
+ // scanned source — these will be purged and may indicate a missing scan source
753
+ // (e.g. a JS file that toggles classes via classList but wasn't picked up).
754
+ // Only warn for project-owned CSS files (not pulse-ui.css framework styles).
755
+ if (!cssHref.includes('pulse-ui')) {
756
+ const definedClasses = [...original.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)\s*[{,:\[]/g)]
757
+ .map(m => m[1])
758
+ .filter((cls, i, arr) => arr.indexOf(cls) === i) // dedupe
759
+ const purgedClasses = definedClasses.filter(cls => !usedClasses.has(cls))
760
+ if (purgedClasses.length > 0) {
761
+ console.log(warn(`${cssHref}: ${purgedClasses.length} class${purgedClasses.length === 1 ? '' : 'es'} purged (not found in any scanned source): ${purgedClasses.slice(0, 5).map(c => `.${c}`).join(', ')}${purgedClasses.length > 5 ? ` … +${purgedClasses.length - 5} more` : ''}`))
762
+ }
763
+ }
764
+
701
765
  const purged = minifyCss(purgeCss(original, usedClasses))
702
766
 
703
767
  const hash = createHash('sha256').update(purged).digest('hex').slice(0, 8)
@@ -707,25 +771,24 @@ async function purgeCssStep(pages, manifest, root, outDir) {
707
771
 
708
772
  fs.writeFileSync(outPath, purged)
709
773
 
710
- const origKb = (Buffer.byteLength(original) / 1024).toFixed(1)
711
- const purgKb = (Buffer.byteLength(purged) / 1024).toFixed(1)
712
- const pct = Math.round((1 - Buffer.byteLength(purged) / Buffer.byteLength(original)) * 100)
713
-
714
- // silent — summary table covers this
774
+ const origBytes = Buffer.byteLength(original)
775
+ const purgBytes = Buffer.byteLength(purged)
776
+ const pct = Math.round((1 - purgBytes / origBytes) * 100)
777
+ cssStats[cssHref] = pct
715
778
 
716
779
  manifest[cssHref] = `/dist/${outName}`
717
780
  }
718
781
 
719
782
  // Re-write manifest with CSS entries added
720
783
  fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
721
- // silent — summary table covers this
784
+ return cssStats
722
785
  }
723
786
 
724
787
  // ---------------------------------------------------------------------------
725
788
  // CSS class extractor
726
789
  // ---------------------------------------------------------------------------
727
790
 
728
- function extractUsedClasses(htmlContents, jsContents) {
791
+ function extractUsedClasses(htmlContents, jsContents, safelist = []) {
729
792
  const used = new Set()
730
793
 
731
794
  // Extract from class="..." in both rendered HTML and JS template literals
@@ -751,6 +814,21 @@ function extractUsedClasses(htmlContents, jsContents) {
751
814
  }
752
815
  }
753
816
 
817
+ // Apply safelist from pulse.config.js — css.safelist accepts strings or RegExp.
818
+ // String entries are added directly; RegExp entries are matched against all
819
+ // class names defined in the CSS (resolved later in purgeCss via selectorUsed
820
+ // because we don't have the CSS text here). To handle RegExp, we mark them as
821
+ // a sentinel that selectorUsed checks separately.
822
+ for (const entry of safelist) {
823
+ if (typeof entry === 'string') {
824
+ used.add(entry)
825
+ }
826
+ // RegExp entries are stored on the Set as a symbol property for selectorUsed to check
827
+ }
828
+ if (safelist.some(e => e instanceof RegExp)) {
829
+ used._safelistPatterns = safelist.filter(e => e instanceof RegExp)
830
+ }
831
+
754
832
  return used
755
833
  }
756
834
 
@@ -820,7 +898,17 @@ function parseCssBlocks(css) {
820
898
 
821
899
  /**
822
900
  * Return true if any selector in the rule should be kept.
823
- * Keeps: element selectors, *, :root, and any rule where a used class appears.
901
+ *
902
+ * Matching strategy: check the *rightmost* class token in the selector (the
903
+ * subject of the rule). A rule like `.parent .child` should survive only when
904
+ * `.child` is used — keeping it whenever `.parent` is used would retain dead
905
+ * rules whose actual target class was never referenced.
906
+ *
907
+ * Pseudo-classes/elements and attribute selectors are stripped before checking
908
+ * so `.btn:hover` is treated as `.btn`, `.btn--active::before` as `.btn--active`.
909
+ *
910
+ * Element-only selectors (`a`, `*`, `:root`) and rules that contain no class
911
+ * tokens at all are always kept.
824
912
  */
825
913
  function selectorUsed(selector, usedClasses) {
826
914
  const selectors = selector.split(',').map(s => s.trim())
@@ -832,12 +920,21 @@ function selectorUsed(selector, usedClasses) {
832
920
  .replace(/\[[^\]]*\]/g, '')
833
921
  .trim()
834
922
 
835
- // Keep element selectors, *, :root — no class tokens
923
+ // Keep element-only selectors — no class tokens present
836
924
  if (!base.includes('.')) return true
837
925
 
838
- // Keep if any class in the selector is in the used set
839
- const classes = [...sel.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map(m => m[1])
840
- if (classes.some(cls => usedClasses.has(cls))) return true
926
+ // Extract all class tokens and check the rightmost one (the rule subject).
927
+ // Using the rightmost class correctly handles descendant selectors:
928
+ // .nav .link → subject is .link
929
+ // .btn--active → subject is .btn--active
930
+ const classes = [...base.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map(m => m[1])
931
+ if (classes.length === 0) return true
932
+ const subject = classes[classes.length - 1]
933
+ if (usedClasses.has(subject)) return true
934
+
935
+ // Check RegExp safelist patterns if any were provided
936
+ const patterns = usedClasses._safelistPatterns
937
+ if (patterns && patterns.some(re => re.test(subject))) return true
841
938
  }
842
939
 
843
940
  return false
@@ -868,9 +965,18 @@ function purgeCss(cssText, usedClasses) {
868
965
  const p = block.prelude.toLowerCase()
869
966
  if (/^@(?:keyframes|font-face|charset)/.test(p)) {
870
967
  kept.push(block.raw)
871
- } else if (/^@(?:media|supports|layer)/.test(p)) {
968
+ } else if (/^@(?:media|supports)/.test(p)) {
872
969
  const innerPurged = purgeCss(block.inner, usedClasses)
873
970
  if (innerPurged.trim()) kept.push(`${block.prelude} {\n${innerPurged}\n}`)
971
+ } else if (/^@layer/.test(p)) {
972
+ // Always preserve empty @layer declarations — they establish cascade order
973
+ // and removing them silently changes specificity even when no rules remain.
974
+ const innerPurged = purgeCss(block.inner, usedClasses)
975
+ if (innerPurged.trim()) {
976
+ kept.push(`${block.prelude} {\n${innerPurged}\n}`)
977
+ } else {
978
+ kept.push(`${block.prelude} {}`)
979
+ }
874
980
  } else {
875
981
  kept.push(block.raw) // unknown at-blocks — keep to be safe
876
982
  }
@@ -54,13 +54,9 @@ Use `mcp__chrome-devtools__list_pages` to get all open pages, then call `mcp__ch
54
54
 
55
55
  ### 11. Write verification stamp
56
56
 
57
- Run:
57
+ Call `pulse_stamp`. This writes `.pulse-verified` via the MCP server, which is more reliable than the `date +%s` shell command — the MCP write always lands after all spec edits, avoiding the mtime race condition that can cause the stop hook to block immediately after verification.
58
58
 
59
- ```bash
60
- date +%s > .pulse-verified
61
- ```
62
-
63
- **This must be the last write operation before stopping.** The stop hook compares each edited spec's modification time against this stamp — if any spec is newer than the stamp, the hook blocks. Do not edit any spec file after writing the stamp.
59
+ **This must be the last operation before stopping.** The stop hook compares each edited spec's mtime against this stamp — if any spec is newer than the stamp, the hook blocks. Do not edit any spec file after calling `pulse_stamp`.
64
60
 
65
61
  ### 12. Report
66
62
 
@@ -172,7 +172,7 @@ To add a missing icon: copy a Lucide path (MIT) into `src/ui/icons.js` using `s(
172
172
  | `stat` | `label`, `value`, `change`, `trend` (up/down/neutral), `size` (sm/md/lg — lg for hero KPIs), `center` |
173
173
  | `avatar` | `src`, `alt`, `size` (sm/md/lg/xl), `initials` |
174
174
  | `empty` | `title`, `description`, `action` ({label,href,variant}) |
175
- | `table` | `headers`, `rows` (2D array of HTML strings), `caption` |
175
+ | `table` | `headers`, `rows` (2D array of HTML strings), `caption` | ⚠ Each cell item must be **raw HTML content** — `table()` wraps each item in `<td>` itself. Do NOT pass `<td>...</td>` strings or you will get double-nested cells. |
176
176
  | `spinner` | `size` (sm/md/lg), `color` (accent/muted/white), `label` |
177
177
  | `progress` | `value`, `max` (100), `label`, `showLabel`, `showValue`, `variant`, `size` |
178
178
  | `breadcrumbs` | `items` ([{label,href}] — last item has no href), `separator` |