@invisibleloop/pulse 0.12.2 → 0.12.3

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.
@@ -20,10 +20,26 @@ Before finishing any spec, verify every point below. Fix anything that fails.
20
20
 
21
21
  - **Do not set `hydrate` in specs.** It is auto-derived by the framework from the URL entry passed to `createServer`. Specs with `mutations`, `actions`, or `persist` are hydrated automatically. Purely server-rendered specs get zero JavaScript — no configuration needed.
22
22
 
23
+ - **Always restart the server and reload the browser after every file edit — before checking the result.** The dev server restarts on file changes but the browser tab stays stale. The required sequence is: `pulse_restart_server` → `navigate_page`. Never attempt to debug a visual problem without doing both steps first.
24
+
23
25
  ### Components first
24
26
 
25
27
  - **Before writing any HTML by hand, check `src/ui/index.js`.** There are 50+ components. Use `button`, `card`, `alert`, `input`, `spinner`, `badge`, `modal`, `nav`, `pagination`, `table`, etc. before writing equivalent HTML from scratch.
26
28
 
29
+ - **Never write App Store or Google Play download buttons by hand.** Always use `appBadge({ store: 'apple', href })` and `appBadge({ store: 'google', href })`. Raw `<a>` tags with badge images are incorrect — `appBadge` renders the correct accessible, styled badge for each store.
30
+
31
+ ```js
32
+ import { appBadge } from '@invisibleloop/pulse/ui'
33
+
34
+ // In a hero() actions slot:
35
+ ${hero({
36
+ actions: `
37
+ ${appBadge({ store: 'apple', href: 'https://apps.apple.com/...' })}
38
+ ${appBadge({ store: 'google', href: 'https://play.google.com/...' })}
39
+ `,
40
+ })}
41
+ ```
42
+
27
43
  ### Reuse (DRY)
28
44
 
29
45
  - **Extract a view helper when the same HTML pattern appears 3 or more times in a single spec.** A plain JS function returning an HTML string is sufficient — no framework needed:
@@ -1 +1 @@
1
- 0.10.2
1
+ 0.12.1
@@ -1884,6 +1884,21 @@ hr.ui-divider {
1884
1884
  border-radius: 2px;
1885
1885
  }
1886
1886
 
1887
+ /* Hover tilt — enabled by .ui-phone--tilt */
1888
+ .ui-phone--tilt {
1889
+ transform-style: preserve-3d;
1890
+ transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), box-shadow 0.4s ease;
1891
+ will-change: transform;
1892
+ }
1893
+ .ui-phone--tilt:hover {
1894
+ transform: perspective(900px) rotateY(-8deg) rotateX(3deg);
1895
+ box-shadow:
1896
+ 0 0 0 1px rgba(0, 0, 0, 0.8),
1897
+ 0 2px 0 1px rgba(255, 255, 255, 0.07) inset,
1898
+ 8px 56px 120px rgba(0, 0, 0, 0.65),
1899
+ 4px 16px 40px rgba(0, 0, 0, 0.5);
1900
+ }
1901
+
1887
1902
  /* ─── Footer ─────────────────────────────────────────────────────────────────
1888
1903
  Site footer — logo, nav links, legal text. Stacks on mobile. */
1889
1904
  .ui-footer {
@@ -1906,9 +1921,6 @@ hr.ui-divider {
1906
1921
  color: var(--ui-text);
1907
1922
  text-decoration: none;
1908
1923
  flex-shrink: 0;
1909
- display: flex;
1910
- align-items: center;
1911
- gap: .5rem;
1912
1924
  transition: color .15s;
1913
1925
  }
1914
1926
 
@@ -129,6 +129,9 @@ document.addEventListener('keydown', (e) => {
129
129
  // ─── Nav (mobile burger) ────────────────────────────────────────────────────
130
130
 
131
131
  function initNav(el) {
132
+ if (el.dataset.pulseNavInit) return
133
+ el.dataset.pulseNavInit = '1'
134
+
132
135
  const burger = el.querySelector('.ui-nav-burger')
133
136
  const mobile = el.querySelector('.ui-nav-mobile')
134
137
 
@@ -170,6 +173,9 @@ document.addEventListener('pulse:navigate', runNavs)
170
173
  // ─── Carousel ───────────────────────────────────────────────────────────────
171
174
 
172
175
  function initCarousel(el) {
176
+ if (el.dataset.pulseCarouselInit) return
177
+ el.dataset.pulseCarouselInit = '1'
178
+
173
179
  const track = el.querySelector('.ui-carousel-track')
174
180
  const prev = el.querySelector('.ui-carousel-prev')
175
181
  const next = el.querySelector('.ui-carousel-next')
@@ -6,15 +6,22 @@ import { phoneFrame } from '../../../../src/ui/index.js'
6
6
  const { prev, next } = prevNext('/components/phone-frame')
7
7
 
8
8
  const sampleScreen = `
9
- <div style="background:#1a2c24;height:100%;padding:0.75rem;display:flex;flex-direction:column;gap:0.625rem;font-family:system-ui,sans-serif;">
9
+ <div style="background:#1a2c24;min-height:520px;padding:0.75rem;display:flex;flex-direction:column;gap:0.75rem;font-family:system-ui,sans-serif;">
10
10
  <div style="display:flex;justify-content:space-between;align-items:center;padding:0 0.25rem;font-size:0.6rem;color:rgba(255,255,255,0.5)">
11
11
  <span>9:41</span>
12
12
  <svg width="20" height="8" viewBox="0 0 20 8" fill="none" aria-hidden="true"><rect x="0.5" y="0.5" width="16" height="7" rx="2" stroke="rgba(255,255,255,0.35)" stroke-width="1"/><rect x="1.5" y="1.5" width="12" height="5" rx="1" fill="rgba(255,255,255,0.7)"/><path d="M18 3v2c.8-.3.8-1.7 0-2z" fill="rgba(255,255,255,0.6)"/></svg>
13
13
  </div>
14
14
  <div style="font-size:0.85rem;font-weight:700;color:#fff">Good morning, Sarah</div>
15
- <div style="background:rgba(255,255,255,0.06);border-radius:0.625rem;padding:0.625rem;flex:1">
16
- <div style="font-size:0.6rem;color:rgba(255,255,255,0.5);margin-bottom:0.3rem">TODAY'S ENTRY</div>
17
- <div style="font-size:0.72rem;color:rgba(255,255,255,0.85);line-height:1.5">Feeling grateful today. The early morning light…</div>
15
+ <div style="background:rgba(255,255,255,0.06);border-radius:0.75rem;padding:0.75rem;flex:1;display:flex;flex-direction:column;gap:0.5rem">
16
+ <div style="font-size:0.6rem;color:rgba(255,255,255,0.5);letter-spacing:0.04em">TODAY'S ENTRY</div>
17
+ <div style="font-size:0.72rem;color:rgba(255,255,255,0.85);line-height:1.6">Feeling grateful today. The early morning light was something else — golden and soft through the curtains.</div>
18
+ </div>
19
+ <div style="background:rgba(255,255,255,0.04);border-radius:0.75rem;padding:0.75rem">
20
+ <div style="font-size:0.6rem;color:rgba(255,255,255,0.5);letter-spacing:0.04em;margin-bottom:0.4rem">STREAK</div>
21
+ <div style="display:flex;gap:0.3rem">
22
+ ${[1,2,3,4,5,6,7].map(d => `<div style="flex:1;height:28px;background:${d < 6 ? 'rgba(74,222,128,0.7)' : 'rgba(255,255,255,0.08)'};border-radius:4px"></div>`).join('')}
23
+ </div>
24
+ <div style="font-size:0.6rem;color:rgba(255,255,255,0.4);margin-top:0.4rem;text-align:right">5 day streak 🔥</div>
18
25
  </div>
19
26
  </div>`
20
27
 
@@ -36,17 +43,29 @@ export default {
36
43
  ${demo(
37
44
  phoneFrame({ content: sampleScreen }),
38
45
  `phoneFrame({
39
- content: \`<div style="background:#1a2c24;height:100%;padding:0.75rem;">
46
+ content: \`<div style="background:#1a2c24;min-height:520px;padding:0.75rem;">
40
47
  <!-- your app screen UI -->
41
48
  </div>\`,
42
49
  })`,
43
50
  )}
44
51
 
52
+ <h2>Hover animation</h2>
53
+ <p>Pass <code>animate: true</code> to enable a gentle 3-D tilt when the user hovers over the frame. Ideal for hero sections — hover the phone below to see it.</p>
54
+
55
+ ${demo(
56
+ phoneFrame({ content: sampleScreen, animate: true }),
57
+ `phoneFrame({
58
+ content: \`...\`,
59
+ animate: true,
60
+ })`,
61
+ )}
62
+
45
63
  ${table(
46
64
  ['Prop', 'Type', 'Default', 'Description'],
47
65
  [
48
- ['<code>content</code>', 'string (HTML)', "''", 'HTML rendered inside the screen area'],
49
- ['<code>class</code>', 'string', "''", 'Extra CSS classes on the root element'],
66
+ ['<code>content</code>', 'string (HTML)', "''", 'HTML rendered inside the screen area'],
67
+ ['<code>animate</code>', 'boolean', 'false', 'Enables a gentle 3-D tilt on mouse hover — recommended for hero usage'],
68
+ ['<code>class</code>', 'string', "''", 'Extra CSS classes on the root element'],
50
69
  ]
51
70
  )}
52
71
  `,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -59,7 +59,8 @@
59
59
  "build": "node scripts/build.js",
60
60
  "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/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",
61
61
  "typecheck": "tsc --noEmit",
62
- "docs": "node --watch src/cli/dev.js --root docs --port 4000",
62
+ "sync:docs": "cp public/pulse-ui.css docs/public/pulse-ui.css && cp public/pulse-ui.js docs/public/pulse-ui.js",
63
+ "docs": "npm run sync:docs && node --watch src/cli/dev.js --root docs --port 4000",
63
64
  "docs:start": "node docs/server.js"
64
65
  },
65
66
  "engines": {
@@ -1 +1 @@
1
- 0.12.2
1
+ 0.12.3
@@ -1884,6 +1884,21 @@ hr.ui-divider {
1884
1884
  border-radius: 2px;
1885
1885
  }
1886
1886
 
1887
+ /* Hover tilt — enabled by .ui-phone--tilt */
1888
+ .ui-phone--tilt {
1889
+ transform-style: preserve-3d;
1890
+ transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), box-shadow 0.4s ease;
1891
+ will-change: transform;
1892
+ }
1893
+ .ui-phone--tilt:hover {
1894
+ transform: perspective(900px) rotateY(-8deg) rotateX(3deg);
1895
+ box-shadow:
1896
+ 0 0 0 1px rgba(0, 0, 0, 0.8),
1897
+ 0 2px 0 1px rgba(255, 255, 255, 0.07) inset,
1898
+ 8px 56px 120px rgba(0, 0, 0, 0.65),
1899
+ 4px 16px 40px rgba(0, 0, 0, 0.5);
1900
+ }
1901
+
1887
1902
  /* ─── Footer ─────────────────────────────────────────────────────────────────
1888
1903
  Site footer — logo, nav links, legal text. Stacks on mobile. */
1889
1904
  .ui-footer {
@@ -129,6 +129,9 @@ document.addEventListener('keydown', (e) => {
129
129
  // ─── Nav (mobile burger) ────────────────────────────────────────────────────
130
130
 
131
131
  function initNav(el) {
132
+ if (el.dataset.pulseNavInit) return
133
+ el.dataset.pulseNavInit = '1'
134
+
132
135
  const burger = el.querySelector('.ui-nav-burger')
133
136
  const mobile = el.querySelector('.ui-nav-mobile')
134
137
 
@@ -170,6 +173,9 @@ document.addEventListener('pulse:navigate', runNavs)
170
173
  // ─── Carousel ───────────────────────────────────────────────────────────────
171
174
 
172
175
  function initCarousel(el) {
176
+ if (el.dataset.pulseCarouselInit) return
177
+ el.dataset.pulseCarouselInit = '1'
178
+
173
179
  const track = el.querySelector('.ui-carousel-track')
174
180
  const prev = el.querySelector('.ui-carousel-prev')
175
181
  const next = el.querySelector('.ui-carousel-next')
@@ -148,7 +148,7 @@ Use `prose()` for HTML from external sources (CMS, markdown). Use `heading()`/`l
148
148
  |-----------|-----------|
149
149
  | `nav` | `logo` (HTML slot), `logoHref`, `links` ([{label,href}]), `action` (HTML slot), `sticky`, `background` (any CSS value — overrides default surface colour), `color` (foreground colour — set when custom background doesn't contrast with default muted text) |
150
150
  | `hero` | `eyebrow`, `title`, `subtitle`, `actions` (HTML slot), `image` (HTML slot — activates split layout), `imageAlign` (right/left), `align` (center/left), `size` (md/sm), `gradient` (true/false or preset: 'purple'\|'blue'\|'green'\|'rose'\|'orange'), `background` (any CSS value — solid colour or custom gradient, overrides gradient preset), `eyebrowColor` (overrides eyebrow text colour — always set when using a custom background to ensure WCAG contrast) |
151
- | `phoneFrame` | `content` (HTML slot — rendered inside the phone screen). Pure CSS phone mockup with dynamic island, side buttons, home indicator. Use as the `image` slot in `hero()` for app landing pages. |
151
+ | `phoneFrame` | `content` (HTML slot — rendered inside the phone screen), `animate` (boolean, default `false` — enables a gentle 3-D tilt on hover). Pure CSS phone mockup with dynamic island, side buttons, home indicator. Use as the `image` slot in `hero()` for app landing pages. Add `animate: true` whenever the phone is a prominent hero element. |
152
152
  | `feature` | `icon` (HTML slot), `title`, `level` (1–6, default 3), `description`, `center` |
153
153
  | `testimonial` | `quote`, `name`, `role`, `src`, `rating` (1–5) |
154
154
  | `pricing` | `name`, `level` (1–6, default 3), `price`, `period`, `features` ([strings]), `action` (HTML slot), `highlighted` |
@@ -5,13 +5,14 @@
5
5
  * Renders the phone body, dynamic island, side buttons, and home indicator.
6
6
  * No images — pure CSS/HTML.
7
7
  *
8
- * @param {object} opts
9
- * @param {string} opts.content - HTML to render inside the screen area
10
- * @param {string} opts.class
8
+ * @param {object} opts
9
+ * @param {string} opts.content - HTML to render inside the screen area
10
+ * @param {boolean} opts.animate - enable gentle 3-D tilt on mouse hover (default: false)
11
+ * @param {string} opts.class
11
12
  */
12
13
 
13
- export function phoneFrame({ content = '', class: cls = '' } = {}) {
14
- const classes = ['ui-phone', cls].filter(Boolean).join(' ')
14
+ export function phoneFrame({ content = '', animate = false, class: cls = '' } = {}) {
15
+ const classes = ['ui-phone', animate && 'ui-phone--tilt', cls].filter(Boolean).join(' ')
15
16
 
16
17
  return `<div class="${classes}" role="img" aria-label="Phone screen preview">
17
18
  <!-- Left buttons: volume up, volume down, mute -->