@invisibleloop/pulse 0.20.57 → 0.20.59

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.20.57",
3
+ "version": "0.20.59",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -76,7 +76,7 @@
76
76
  "scripts": {
77
77
  "dev": "node --watch src/cli/dev.js",
78
78
  "build": "node scripts/build.js",
79
- "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/cli/validate.test.js && node src/cli/diagnose.test.js && node src/cli/check-bundles.test.js && node src/cli/review.test.js && node src/cli/process-utils.test.js && node src/cli/stamp.test.js && node src/cli/feedback.test.js && node src/cli/update.test.js && node src/cli/dev-host.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node scripts/release-version.test.js && node src/mcp/mcp.test.js && node src/mcp/guards.test.js",
79
+ "test": "node src/spec/schema.test.js && node src/runtime/runtime.test.js && node src/runtime/navigate.test.js && node src/runtime/morph.test.js && node src/runtime/ssr.test.js && node src/runtime/image.test.js && node src/server/server.test.js && node src/cli/cli.test.js && node src/cli/validate.test.js && node src/cli/diagnose.test.js && node src/cli/check-bundles.test.js && node src/cli/review.test.js && node src/cli/process-utils.test.js && node src/cli/stamp.test.js && node src/cli/feedback.test.js && node src/cli/update.test.js && node src/cli/dev-host.test.js && node src/cli/dev-reload.test.js && node src/ui/ui.test.js && node src/testing/testing.test.js && node src/store/store.test.js && node src/md/md.test.js && node scripts/strip-server.test.js && node scripts/css-purge.test.js && node scripts/release-version.test.js && node src/mcp/mcp.test.js && node src/mcp/guards.test.js",
80
80
  "typecheck": "tsc --noEmit",
81
81
  "docs": "node --watch src/cli/dev.js --root ../../docs --port 4000",
82
82
  "docs:start": "node ../../docs/server.js"
@@ -1 +1 @@
1
- 0.20.57
1
+ 0.20.59
@@ -3026,3 +3026,9 @@ fieldset[disabled] .ui-rating-stars .ui-rating-star:hover ~ .ui-rating-star { co
3026
3026
  line-height: 0;
3027
3027
  }
3028
3028
 
3029
+ /* ─── Framework skip link ───────────────────────────────────────────────────
3030
+ * Injected by the server's buffered render path (404s, POST re-renders, stream: false).
3031
+ * Hidden until keyboard focus, like the one in pulse.css — pages that load only
3032
+ * pulse-ui.css must not see it as a plain link at the top of the page. */
3033
+ .pulse-skip-link{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden}
3034
+ .pulse-skip-link:focus{position:fixed;left:1rem;top:1rem;width:auto;height:auto;padding:.5rem 1rem;background:#000;color:#fff;font-size:1rem;font-weight:600;border-radius:4px;text-decoration:none;z-index:9999;outline:3px solid #fff;outline-offset:2px}
@@ -9,8 +9,13 @@
9
9
  // CSS class extractor
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
+ // Classes the framework itself writes into the document (src/runtime/ssr.js wrapDocument),
13
+ // outside any page's view, so nothing the build scans ever contains them. Always kept:
14
+ // without this a built app loses the rules that hide the injected skip link (#161).
15
+ export const FRAMEWORK_INJECTED_CLASSES = ['pulse-skip-link']
16
+
12
17
  export function extractUsedClasses(htmlContents, jsContents, safelist = []) {
13
- const used = new Set()
18
+ const used = new Set(FRAMEWORK_INJECTED_CLASSES)
14
19
 
15
20
  // Extract from class="..." in rendered HTML (no template expressions to worry about)
16
21
  for (const content of htmlContents) {
package/src/cli/dev.js CHANGED
@@ -10,6 +10,8 @@
10
10
 
11
11
  import path from 'path'
12
12
  import fs from 'fs'
13
+ import { register } from 'node:module'
14
+ import { pathToFileURL } from 'node:url'
13
15
  import { createServer } from '../server/index.js'
14
16
  import { loadPages, loadStore } from './discover.js'
15
17
  import * as log from './logger.js'
@@ -31,6 +33,14 @@ const ROOT = rootArg !== -1
31
33
  // resolve to the project root, not wherever the parent process was launched from.
32
34
  if (process.cwd() !== ROOT) process.chdir(ROOT)
33
35
 
36
+ // Hot reload re-imports each page with a cache-busting stamp; this hook passes the stamp on
37
+ // to the project modules the page imports, so editing src/lib or src/components takes
38
+ // effect too (see reload-hooks.js). realpath: Node reports resolved URLs without symlinks.
39
+ register('./reload-hooks.js', {
40
+ parentURL: import.meta.url,
41
+ data: { rootUrl: pathToFileURL(fs.realpathSync(ROOT)).href + '/' },
42
+ })
43
+
34
44
  let _config = {}
35
45
  const configPath = path.join(ROOT, 'pulse.config.js')
36
46
  if (fs.existsSync(configPath)) {
@@ -14,6 +14,7 @@
14
14
 
15
15
  import fs from 'fs'
16
16
  import path from 'path'
17
+ import { pathToFileURL } from 'url'
17
18
 
18
19
  /**
19
20
  * Derive a route from a file path relative to the pages directory.
@@ -136,7 +137,7 @@ export async function loadStore(projectRoot, cacheBust = null) {
136
137
  const storePath = path.join(projectRoot, 'pulse.store.js')
137
138
  if (!fs.existsSync(storePath)) return null
138
139
 
139
- const url = 'file://' + storePath + (cacheBust ? `?t=${cacheBust}` : '')
140
+ const url = pathToFileURL(storePath).href + (cacheBust ? `?t=${cacheBust}` : '')
140
141
  const mod = await import(url)
141
142
  const def = mod.default
142
143
  if (!def || typeof def !== 'object') {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Pulse — module-loader hook for dev hot reload
3
+ *
4
+ * `pulse dev` re-imports each page with `?t=<stamp>` to dodge Node's ESM cache, but that
5
+ * only refreshes the page file: everything the page imports (src/lib, src/components, ...)
6
+ * resolves to the same URL as before and comes back stale from the cache. This hook gives
7
+ * a project module imported by a stamped module the same stamp, so the whole project
8
+ * graph is re-evaluated on a reload.
9
+ *
10
+ * Only the project's own files are stamped. Anything in node_modules, the framework
11
+ * itself, and `node:` builtins keep their URL, so packages (and framework singletons)
12
+ * are evaluated once rather than once per edit.
13
+ *
14
+ * Limits: CommonJS files (.cjs) are cached by path, so the stamp cannot refresh them, and
15
+ * modules outside the project root (e.g. a sibling workspace package) are neither stamped
16
+ * nor watched.
17
+ */
18
+
19
+ let rootUrl = ''
20
+
21
+ export function initialize(data) {
22
+ rootUrl = data.rootUrl
23
+ }
24
+
25
+ export async function resolve(specifier, context, nextResolve) {
26
+ const result = await nextResolve(specifier, context)
27
+ const url = result.url
28
+ if (!rootUrl || !url.startsWith(rootUrl) || url.includes('?')) return result
29
+ if (url.slice(rootUrl.length).includes('node_modules/')) return result // below the root: a project may itself live under a node_modules folder
30
+ if (!context.parentURL?.startsWith(rootUrl)) return result
31
+
32
+ const stamp = new URL(context.parentURL).searchParams.get('t')
33
+ if (!stamp || !/^\d+$/.test(stamp)) return result
34
+ return { ...result, url: `${url}?t=${stamp}` }
35
+ }
@@ -605,7 +605,7 @@ Rules:
605
605
  Every page must be fully navigable by keyboard alone.
606
606
 
607
607
  **Structure**
608
- - Wrap page content in \`<main id="main-content">\` — the skip link injected by the framework targets this id
608
+ - Wrap page content in \`<main id="main-content">\` — a skip link targets this id (add your own to the layout; the framework only injects a fallback on 404s and form re-renders)
609
609
  - Use semantic elements: \`<nav>\`, \`<main>\`, \`<header>\`, \`<footer>\`, \`<section>\`, \`<article>\`, \`<aside>\`
610
610
  - One \`<h1>\` per page — the primary heading that describes the current page
611
611
 
@@ -896,7 +896,7 @@ Threshold merge order: **global config → environment override → per-route ov
896
896
  - **Redirect loops** — if \`/dashboard\` guards redirect to \`/login\`, and \`/login\` guards redirect authenticated users to \`/dashboard\`, test both directions to confirm there is no loop.
897
897
 
898
898
  ### Performance & Lighthouse
899
- - **Missing \`<main id="main-content">\`** — the framework injects a skip link targeting \`#main-content\` on every page. A missing element breaks the skip link and fails Lighthouse Accessibility.
899
+ - **Missing \`<main id="main-content">\`** — skip links target \`#main-content\`. A missing element breaks the skip link and fails Lighthouse Accessibility.
900
900
  - **Multiple \`<h1>\` elements** — one \`<h1>\` per page. Multiple at the same level fail Lighthouse Accessibility.
901
901
  - **Images without \`width\` and \`height\`** — omitting dimensions prevents the browser from reserving space, causing layout shift and a non-zero CLS. Always provide both.
902
902
  - **Colour contrast** — \`#888\` on \`#111\` is the practical minimum for muted text that passes WCAG AA. Lighter greys will fail. Check new colour tokens before committing.
@@ -909,7 +909,7 @@ Threshold merge order: **global config → environment override → per-route ov
909
909
  ## Rules the agent must follow
910
910
 
911
911
  - **Never set \`hydrate\`** — the framework sets it automatically.
912
- - **Always wrap page content in \`<main id="main-content">\`** — the framework injects a skip link targeting this id on every page.
912
+ - **Always wrap page content in \`<main id="main-content">\`** — skip links target this id.
913
913
  - **Never use \`data-event\` on text inputs** to mirror value into state. It destroys focus on every keystroke. Use uncontrolled inputs and read values from \`FormData\` in \`action.onStart\` instead. For client-side filtering/search, render all items in the HTML and use an inline \`<script>\` to show/hide elements — no state or re-render needed.
914
914
  - **Never add \`<script>\` tags manually** — hydration is handled by the framework.
915
915
  - **Never add external npm packages** for client-side behaviour — express it in the spec.
@@ -343,6 +343,10 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
343
343
  const meta = spec.meta || {}
344
344
  const title = meta.title || 'Pulse'
345
345
 
346
+ // The fallback skip link is only for pages that have none: a page (or its layout) that
347
+ // already links to #main-content supplies its own, and two would be a redundant tab stop.
348
+ const ownSkipLink = /href=["']#main-content["']/.test(content)
349
+
346
350
  const bodyAttr = [
347
351
  meta.theme ? `data-theme="${esc(meta.theme)}"` : '',
348
352
  meta.vibe ? `data-vibe="${esc(meta.vibe)}"` : '',
@@ -419,7 +423,7 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
419
423
  ${schemaScript}
420
424
  </head>
421
425
  <body${bodyAttr}>
422
- <a href="#main-content" class="pulse-skip-link">Skip to main content</a>
426
+ ${ownSkipLink ? '' : '<a href="#main-content" class="pulse-skip-link">Skip to main content</a>'}
423
427
  <div id="pulse-root">
424
428
  ${content}
425
429
  </div>