@invisibleloop/pulse 0.1.21
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/.claude/commands/build-page.md +59 -0
- package/.claude/commands/new-doc-page.md +45 -0
- package/.claude/commands/verify.md +52 -0
- package/.claude/pulse-checklist.md +111 -0
- package/.claude/settings.local.json +102 -0
- package/.github/workflows/ci.yml +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.pulse/load-reports/home/1773432711417.json +22 -0
- package/CLAUDE.md +383 -0
- package/README.md +95 -0
- package/docs/.claude/pulse-checklist.md +111 -0
- package/docs/public/.pulse-ui-version +1 -0
- package/docs/public/dist/accessibility.boot-5DVTARJU.js +115 -0
- package/docs/public/dist/actions.boot-P66HKQEM.js +164 -0
- package/docs/public/dist/auth.boot-IMAJAUPH.js +140 -0
- package/docs/public/dist/caching.boot-DVR6KDE7.js +53 -0
- package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +11 -0
- package/docs/public/dist/components--alert.boot-GCEXOZAC.js +6 -0
- package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +6 -0
- package/docs/public/dist/components--avatar.boot-PSW24EVA.js +5 -0
- package/docs/public/dist/components--badge.boot-TYDY2RMK.js +7 -0
- package/docs/public/dist/components--banner.boot-EI5PZSZK.js +7 -0
- package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +34 -0
- package/docs/public/dist/components--button.boot-J54BQM2E.js +23 -0
- package/docs/public/dist/components--card.boot-PZGNDIB6.js +138 -0
- package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +12 -0
- package/docs/public/dist/components--charts.boot-2EOYQWKL.js +108 -0
- package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +54 -0
- package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +9 -0
- package/docs/public/dist/components--code-window.boot-2GR2DV33.js +20 -0
- package/docs/public/dist/components--container.boot-7LOOGK2K.js +5 -0
- package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +11 -0
- package/docs/public/dist/components--divider.boot-3NI2C3QG.js +6 -0
- package/docs/public/dist/components--empty.boot-YX2UR3PV.js +7 -0
- package/docs/public/dist/components--feature.boot-MUD7NSUO.js +13 -0
- package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +19 -0
- package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +52 -0
- package/docs/public/dist/components--footer.boot-EYUK5FRG.js +14 -0
- package/docs/public/dist/components--grid.boot-URDQVDDR.js +59 -0
- package/docs/public/dist/components--heading.boot-BPQKU43E.js +44 -0
- package/docs/public/dist/components--hero.boot-4RAPRGAB.js +17 -0
- package/docs/public/dist/components--icons.boot-ZITNU5JP.js +68 -0
- package/docs/public/dist/components--image.boot-XEEGHQZF.js +19 -0
- package/docs/public/dist/components--input.boot-SGASZG5K.js +7 -0
- package/docs/public/dist/components--list.boot-W3XC5MHD.js +55 -0
- package/docs/public/dist/components--media.boot-5VFIETZO.js +13 -0
- package/docs/public/dist/components--modal.boot-RZUYXBN2.js +47 -0
- package/docs/public/dist/components--nav.boot-ODBOHU7O.js +33 -0
- package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +21 -0
- package/docs/public/dist/components--progress.boot-GHAGYZOK.js +30 -0
- package/docs/public/dist/components--prose.boot-QANJL6JI.js +67 -0
- package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +22 -0
- package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +75 -0
- package/docs/public/dist/components--rating.boot-QBAN6DEL.js +38 -0
- package/docs/public/dist/components--search.boot-PXH5O5AG.js +17 -0
- package/docs/public/dist/components--section.boot-AQGIYHWW.js +12 -0
- package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +33 -0
- package/docs/public/dist/components--select.boot-47X5RHOC.js +10 -0
- package/docs/public/dist/components--slider.boot-PSRRX7XL.js +47 -0
- package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +22 -0
- package/docs/public/dist/components--stack.boot-DI4NJXBF.js +9 -0
- package/docs/public/dist/components--stat.boot-QMFUWBQT.js +9 -0
- package/docs/public/dist/components--stepper.boot-34PP2NEV.js +22 -0
- package/docs/public/dist/components--table.boot-FCQGSFIQ.js +11 -0
- package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +11 -0
- package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +4 -0
- package/docs/public/dist/components--timeline.boot-26LN52P2.js +95 -0
- package/docs/public/dist/components--toggle.boot-IQQEI76S.js +29 -0
- package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +9 -0
- package/docs/public/dist/components.boot-SE6PQ4P7.js +103 -0
- package/docs/public/dist/config.boot-DTRRWUE6.js +126 -0
- package/docs/public/dist/constraints.boot-DUHDZBMC.js +71 -0
- package/docs/public/dist/deploy.boot-SLAD3NI2.js +163 -0
- package/docs/public/dist/docs-8e3d4b5c.css +1 -0
- package/docs/public/dist/extending.boot-UA3CN243.js +159 -0
- package/docs/public/dist/faq.boot-6EQAWLQR.js +43 -0
- package/docs/public/dist/getting-started.boot-TDKIFL5U.js +86 -0
- package/docs/public/dist/guard.boot-AUHAWTG4.js +80 -0
- package/docs/public/dist/home.boot-BVQXRH32.js +383 -0
- package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +104 -0
- package/docs/public/dist/hydration.boot-JRM6IPJL.js +78 -0
- package/docs/public/dist/images.boot-M6ZVKTZS.js +80 -0
- package/docs/public/dist/manifest.json +94 -0
- package/docs/public/dist/meta.boot-7NXGPHR4.js +79 -0
- package/docs/public/dist/mutations.boot-F6F43UDX.js +79 -0
- package/docs/public/dist/navigation.boot-AOXWS3ZF.js +57 -0
- package/docs/public/dist/performance.boot-C3UPCOBK.js +98 -0
- package/docs/public/dist/persist.boot-WT32PQOQ.js +61 -0
- package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +63 -0
- package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +31 -0
- package/docs/public/dist/pulse-ui-81a85c03.css +1 -0
- package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +104 -0
- package/docs/public/dist/routing.boot-FNX5FDGH.js +70 -0
- package/docs/public/dist/runtime-B73WLANC.js +1 -0
- package/docs/public/dist/runtime-KO4BHUQ3.js +49 -0
- package/docs/public/dist/runtime-L2HNXIHW.js +59 -0
- package/docs/public/dist/runtime-QFURDKA2.js +5 -0
- package/docs/public/dist/runtime-UVPXO4IR.js +375 -0
- package/docs/public/dist/runtime-VMJA3Z4N.js +10 -0
- package/docs/public/dist/runtime-ZJ4FXT5O.js +11 -0
- package/docs/public/dist/server-api.boot-K7X3LCFB.js +219 -0
- package/docs/public/dist/server-data.boot-Y7HQYC4R.js +157 -0
- package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +26 -0
- package/docs/public/dist/spec.boot-2WU7ZHCV.js +159 -0
- package/docs/public/dist/state.boot-B24GUE3R.js +73 -0
- package/docs/public/dist/store.boot-TLIB4XHH.js +150 -0
- package/docs/public/dist/streaming.boot-W2DZSMW4.js +80 -0
- package/docs/public/dist/stripe.boot-QN3C2GEL.js +164 -0
- package/docs/public/dist/supabase.boot-BG4XXLZE.js +303 -0
- package/docs/public/dist/testing.boot-6U4WKMTE.js +130 -0
- package/docs/public/dist/validation.boot-PQHYGW5B.js +100 -0
- package/docs/public/docs.css +2020 -0
- package/docs/public/menu.js +83 -0
- package/docs/public/pulse-ui.css +2739 -0
- package/docs/public/pulse-ui.js +236 -0
- package/docs/server.js +192 -0
- package/docs/src/lib/component-page.js +47 -0
- package/docs/src/lib/highlight.js +255 -0
- package/docs/src/lib/layout.js +131 -0
- package/docs/src/lib/metrics-store.js +6 -0
- package/docs/src/lib/nav.js +159 -0
- package/docs/src/lib/stats.js +81 -0
- package/docs/src/pages/accessibility.js +157 -0
- package/docs/src/pages/actions.js +191 -0
- package/docs/src/pages/auth.js +177 -0
- package/docs/src/pages/caching.js +95 -0
- package/docs/src/pages/components/accordion.js +48 -0
- package/docs/src/pages/components/alert.js +35 -0
- package/docs/src/pages/components/app-badge.js +41 -0
- package/docs/src/pages/components/avatar.js +35 -0
- package/docs/src/pages/components/badge.js +36 -0
- package/docs/src/pages/components/banner.js +45 -0
- package/docs/src/pages/components/breadcrumbs.js +94 -0
- package/docs/src/pages/components/button.js +84 -0
- package/docs/src/pages/components/card.js +225 -0
- package/docs/src/pages/components/carousel.js +72 -0
- package/docs/src/pages/components/charts.js +278 -0
- package/docs/src/pages/components/checkbox.js +129 -0
- package/docs/src/pages/components/cluster.js +47 -0
- package/docs/src/pages/components/code-window.js +57 -0
- package/docs/src/pages/components/container.js +40 -0
- package/docs/src/pages/components/cta.js +53 -0
- package/docs/src/pages/components/divider.js +37 -0
- package/docs/src/pages/components/empty.js +36 -0
- package/docs/src/pages/components/feature.js +60 -0
- package/docs/src/pages/components/fieldset.js +65 -0
- package/docs/src/pages/components/fileupload.js +127 -0
- package/docs/src/pages/components/footer.js +58 -0
- package/docs/src/pages/components/grid.js +165 -0
- package/docs/src/pages/components/heading.js +107 -0
- package/docs/src/pages/components/hero.js +65 -0
- package/docs/src/pages/components/icons.js +285 -0
- package/docs/src/pages/components/image.js +71 -0
- package/docs/src/pages/components/input.js +51 -0
- package/docs/src/pages/components/list.js +112 -0
- package/docs/src/pages/components/media.js +51 -0
- package/docs/src/pages/components/modal.js +111 -0
- package/docs/src/pages/components/nav.js +86 -0
- package/docs/src/pages/components/pricing.js +68 -0
- package/docs/src/pages/components/progress.js +102 -0
- package/docs/src/pages/components/prose.js +111 -0
- package/docs/src/pages/components/pullquote.js +71 -0
- package/docs/src/pages/components/radio.js +194 -0
- package/docs/src/pages/components/rating.js +106 -0
- package/docs/src/pages/components/search.js +61 -0
- package/docs/src/pages/components/section.js +59 -0
- package/docs/src/pages/components/segmented.js +121 -0
- package/docs/src/pages/components/select.js +45 -0
- package/docs/src/pages/components/slider.js +114 -0
- package/docs/src/pages/components/spinner.js +73 -0
- package/docs/src/pages/components/stack.js +48 -0
- package/docs/src/pages/components/stat.js +55 -0
- package/docs/src/pages/components/stepper.js +66 -0
- package/docs/src/pages/components/table.js +45 -0
- package/docs/src/pages/components/testimonial.js +49 -0
- package/docs/src/pages/components/textarea.js +31 -0
- package/docs/src/pages/components/timeline.js +227 -0
- package/docs/src/pages/components/toggle.js +84 -0
- package/docs/src/pages/components/tooltip.js +48 -0
- package/docs/src/pages/components.js +204 -0
- package/docs/src/pages/config.js +193 -0
- package/docs/src/pages/constraints.js +99 -0
- package/docs/src/pages/deploy.js +233 -0
- package/docs/src/pages/extending.js +198 -0
- package/docs/src/pages/faq.js +96 -0
- package/docs/src/pages/getting-started.js +106 -0
- package/docs/src/pages/guard.js +121 -0
- package/docs/src/pages/home.js +401 -0
- package/docs/src/pages/how-it-works.js +183 -0
- package/docs/src/pages/hydration.js +98 -0
- package/docs/src/pages/images.js +121 -0
- package/docs/src/pages/meta.js +120 -0
- package/docs/src/pages/mutations.js +106 -0
- package/docs/src/pages/navigation.js +85 -0
- package/docs/src/pages/performance.js +157 -0
- package/docs/src/pages/persist.js +88 -0
- package/docs/src/pages/project-structure.js +90 -0
- package/docs/src/pages/prompt-examples.js +186 -0
- package/docs/src/pages/raw-responses.js +124 -0
- package/docs/src/pages/routing.js +99 -0
- package/docs/src/pages/server-api.js +281 -0
- package/docs/src/pages/server-data.js +185 -0
- package/docs/src/pages/slash-commands.js +55 -0
- package/docs/src/pages/spec.js +207 -0
- package/docs/src/pages/state.js +101 -0
- package/docs/src/pages/store.js +181 -0
- package/docs/src/pages/streaming.js +108 -0
- package/docs/src/pages/stripe.js +193 -0
- package/docs/src/pages/supabase.js +323 -0
- package/docs/src/pages/testing.js +198 -0
- package/docs/src/pages/validation.js +138 -0
- package/examples/contact.js +166 -0
- package/examples/counter.js +94 -0
- package/examples/dev.server.js +91 -0
- package/examples/examples.test.js +394 -0
- package/examples/pricing.js +244 -0
- package/examples/products.js +191 -0
- package/examples/quiz.js +208 -0
- package/examples/shared.js +78 -0
- package/examples/todos.js +162 -0
- package/package.json +75 -0
- package/public/.pulse-ui-version +1 -0
- package/public/chippy-bird.css +246 -0
- package/public/examples/contact.css +119 -0
- package/public/examples/counter.css +79 -0
- package/public/examples/pricing.css +132 -0
- package/public/examples/products.css +100 -0
- package/public/examples/quiz.css +200 -0
- package/public/examples/todos.css +137 -0
- package/public/favicon.ico +0 -0
- package/public/log-dashboard.css +383 -0
- package/public/pulse-ui.css +2740 -0
- package/public/pulse-ui.js +236 -0
- package/public/pulse.css +149 -0
- package/scripts/build.js +411 -0
- package/src/agent/checklist.md +111 -0
- package/src/agent/coverage-check.js +66 -0
- package/src/agent/guide-components.md +274 -0
- package/src/agent/guide-examples.md +54 -0
- package/src/agent/guide-routing.md +36 -0
- package/src/agent/guide-server.md +258 -0
- package/src/agent/guide-spec.md +103 -0
- package/src/agent/guide-styles.md +191 -0
- package/src/agent/guide.md +979 -0
- package/src/agent/identity.md +106 -0
- package/src/agent/workflow.md +108 -0
- package/src/cli/cli.test.js +82 -0
- package/src/cli/dev.js +195 -0
- package/src/cli/discover.js +113 -0
- package/src/cli/index.js +361 -0
- package/src/cli/load-report.js +91 -0
- package/src/cli/load-runner.js +121 -0
- package/src/cli/report-server.js +723 -0
- package/src/cli/report.js +116 -0
- package/src/cli/scaffold.archive.js +1371 -0
- package/src/cli/scaffold.js +349 -0
- package/src/cli/start.js +74 -0
- package/src/html.js +19 -0
- package/src/mcp/server.js +884 -0
- package/src/mcp/validate-worker.js +110 -0
- package/src/runtime/image.js +74 -0
- package/src/runtime/image.test.js +111 -0
- package/src/runtime/index.js +621 -0
- package/src/runtime/navigate.js +146 -0
- package/src/runtime/runtime.test.js +773 -0
- package/src/runtime/ssr.js +464 -0
- package/src/runtime/ssr.test.js +421 -0
- package/src/runtime/store.js +92 -0
- package/src/runtime/toast.js +163 -0
- package/src/server/index.js +1386 -0
- package/src/server/server.test.js +1248 -0
- package/src/spec/schema.js +428 -0
- package/src/spec/schema.test.js +291 -0
- package/src/store/index.js +102 -0
- package/src/store/store.test.js +210 -0
- package/src/testing/html.js +283 -0
- package/src/testing/index.js +249 -0
- package/src/testing/testing.test.js +450 -0
- package/src/ui/accordion.js +28 -0
- package/src/ui/alert.js +43 -0
- package/src/ui/app-badge.js +48 -0
- package/src/ui/avatar.js +47 -0
- package/src/ui/badge.js +24 -0
- package/src/ui/banner.js +26 -0
- package/src/ui/breadcrumbs.js +38 -0
- package/src/ui/button.js +66 -0
- package/src/ui/card.js +34 -0
- package/src/ui/carousel.js +59 -0
- package/src/ui/charts.js +321 -0
- package/src/ui/checkbox.js +65 -0
- package/src/ui/cluster.js +44 -0
- package/src/ui/code-window.js +39 -0
- package/src/ui/container.js +24 -0
- package/src/ui/cta.js +37 -0
- package/src/ui/divider.js +29 -0
- package/src/ui/empty.js +33 -0
- package/src/ui/feature.js +33 -0
- package/src/ui/fieldset.js +37 -0
- package/src/ui/fileupload.js +89 -0
- package/src/ui/footer.js +38 -0
- package/src/ui/grid.js +36 -0
- package/src/ui/heading.js +45 -0
- package/src/ui/hero.js +37 -0
- package/src/ui/icons.js +161 -0
- package/src/ui/index.js +89 -0
- package/src/ui/input.js +74 -0
- package/src/ui/list.js +36 -0
- package/src/ui/media.js +44 -0
- package/src/ui/modal.js +80 -0
- package/src/ui/nav.js +61 -0
- package/src/ui/pricing.js +56 -0
- package/src/ui/progress.js +62 -0
- package/src/ui/prose.js +29 -0
- package/src/ui/pullquote.js +34 -0
- package/src/ui/radio.js +102 -0
- package/src/ui/rating.js +93 -0
- package/src/ui/search.js +77 -0
- package/src/ui/section.js +69 -0
- package/src/ui/segmented.js +50 -0
- package/src/ui/select.js +77 -0
- package/src/ui/slider.js +84 -0
- package/src/ui/spinner.js +34 -0
- package/src/ui/stack.js +36 -0
- package/src/ui/stat.js +52 -0
- package/src/ui/stepper.js +46 -0
- package/src/ui/switch.js +57 -0
- package/src/ui/table.js +45 -0
- package/src/ui/testimonial.js +48 -0
- package/src/ui/textarea.js +72 -0
- package/src/ui/timeline.js +72 -0
- package/src/ui/tooltip.js +28 -0
- package/src/ui/ui.test.js +1241 -0
- package/src/ui/uiimage.js +65 -0
- package/tsconfig.json +13 -0
- package/types/html.d.ts +17 -0
- package/types/image.d.ts +70 -0
- package/types/index.d.ts +7 -0
- package/types/navigate.d.ts +38 -0
- package/types/runtime.d.ts +63 -0
- package/types/schema.d.ts +243 -0
- package/types/server.d.ts +145 -0
- package/types/ssr.d.ts +110 -0
- package/types/testing.d.ts +154 -0
- package/types/ui.d.ts +704 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — Testing helpers tests
|
|
3
|
+
* run: node src/testing/testing.test.js
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { render, renderSync } from './index.js'
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Test runner
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
let passed = 0
|
|
13
|
+
let failed = 0
|
|
14
|
+
|
|
15
|
+
async function test(label, fn) {
|
|
16
|
+
try {
|
|
17
|
+
await fn()
|
|
18
|
+
console.log(` ✓ ${label}`)
|
|
19
|
+
passed++
|
|
20
|
+
} catch (e) {
|
|
21
|
+
console.log(` ✗ ${label}`)
|
|
22
|
+
console.log(` ${e.message}`)
|
|
23
|
+
failed++
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function assert(condition, msg) {
|
|
28
|
+
if (!condition) throw new Error(msg || 'Assertion failed')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function assertEqual(a, b) {
|
|
32
|
+
if (a !== b) throw new Error(`Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Specs used across tests
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const counterSpec = {
|
|
40
|
+
route: '/counter',
|
|
41
|
+
state: { count: 0 },
|
|
42
|
+
view: (state) => `<div class="counter"><span id="count">${state.count}</span></div>`,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const productSpec = {
|
|
46
|
+
route: '/products/:id',
|
|
47
|
+
state: {},
|
|
48
|
+
server: {
|
|
49
|
+
product: async (ctx) => ({ id: ctx.params?.id ?? '1', name: 'Widget', price: 9.99 })
|
|
50
|
+
},
|
|
51
|
+
view: (_s, server) => `
|
|
52
|
+
<main>
|
|
53
|
+
<h1 class="title">${server.product.name}</h1>
|
|
54
|
+
<p class="price">$${server.product.price}</p>
|
|
55
|
+
<button type="button" data-event="buy" disabled>Buy</button>
|
|
56
|
+
</main>
|
|
57
|
+
`,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const formSpec = {
|
|
61
|
+
route: '/contact',
|
|
62
|
+
state: { name: '', email: '' },
|
|
63
|
+
view: (state) => `
|
|
64
|
+
<form data-action="submit">
|
|
65
|
+
<input name="name" type="text" value="${state.name}" required>
|
|
66
|
+
<input name="email" type="email" value="${state.email}">
|
|
67
|
+
<button type="submit">Send</button>
|
|
68
|
+
</form>
|
|
69
|
+
`,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const segmentedSpec = {
|
|
73
|
+
route: '/page',
|
|
74
|
+
state: {},
|
|
75
|
+
view: {
|
|
76
|
+
header: () => '<header><h1>Title</h1></header>',
|
|
77
|
+
body: () => '<main><p>Content</p></main>',
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const listSpec = {
|
|
82
|
+
route: '/items',
|
|
83
|
+
state: {},
|
|
84
|
+
server: {
|
|
85
|
+
items: async () => ['Alpha', 'Beta', 'Gamma']
|
|
86
|
+
},
|
|
87
|
+
view: (_s, server) => `<ul>${server.items.map(i => `<li>${i}</li>`).join('')}</ul>`,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
console.log('\nrenderSync — basic rendering\n')
|
|
93
|
+
|
|
94
|
+
await test('renders view with default state', async () => {
|
|
95
|
+
const result = renderSync(counterSpec)
|
|
96
|
+
assert(result.html.includes('0'), `Expected 0, got: ${result.html}`)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
await test('renders view with state overrides', async () => {
|
|
100
|
+
const result = renderSync(counterSpec, { state: { count: 42 } })
|
|
101
|
+
assert(result.html.includes('42'), `Expected 42, got: ${result.html}`)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
await test('result.state reflects overrides merged with spec defaults', async () => {
|
|
105
|
+
const result = renderSync(counterSpec, { state: { count: 7 } })
|
|
106
|
+
assertEqual(result.state.count, 7)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
await test('renders segmented view by concatenating all segments', async () => {
|
|
110
|
+
const result = renderSync(segmentedSpec)
|
|
111
|
+
assert(result.html.includes('<header>'), `Missing header: ${result.html}`)
|
|
112
|
+
assert(result.html.includes('<main>'), `Missing main: ${result.html}`)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
await test('renders view with mock server state', async () => {
|
|
116
|
+
const result = renderSync(productSpec, { server: { product: { name: 'Gadget', price: 19.99 } } })
|
|
117
|
+
assert(result.html.includes('Gadget'), `Expected Gadget, got: ${result.html}`)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await test('result.server reflects the server state provided', async () => {
|
|
121
|
+
const server = { product: { name: 'Gadget', price: 19.99 } }
|
|
122
|
+
const result = renderSync(productSpec, { server })
|
|
123
|
+
assertEqual(result.server.product.name, 'Gadget')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
console.log('\nrender — async rendering\n')
|
|
129
|
+
|
|
130
|
+
await test('resolves spec.server fetchers and passes to view', async () => {
|
|
131
|
+
const result = await render(productSpec)
|
|
132
|
+
assert(result.html.includes('Widget'), `Expected Widget, got: ${result.html}`)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await test('passes ctx to server fetchers', async () => {
|
|
136
|
+
const result = await render(productSpec, { ctx: { params: { id: '99' } } })
|
|
137
|
+
assert(result.html.includes('Widget'), 'Expected Widget in output')
|
|
138
|
+
assertEqual(result.server.product.id, '99')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
await test('bypasses fetchers when server is provided', async () => {
|
|
142
|
+
let fetcherCalled = false
|
|
143
|
+
const spec = {
|
|
144
|
+
route: '/mock',
|
|
145
|
+
state: {},
|
|
146
|
+
server: { data: async () => { fetcherCalled = true; return 'real' } },
|
|
147
|
+
view: (_s, server) => `<p>${server.data}</p>`,
|
|
148
|
+
}
|
|
149
|
+
const result = await render(spec, { server: { data: 'mocked' } })
|
|
150
|
+
assert(!fetcherCalled, 'Fetcher should not be called when server is provided')
|
|
151
|
+
assert(result.html.includes('mocked'), `Expected mocked, got: ${result.html}`)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
await test('resolves multiple fetchers in parallel', async () => {
|
|
155
|
+
const result = await render(listSpec)
|
|
156
|
+
assert(result.html.includes('Alpha'), `Missing Alpha: ${result.html}`)
|
|
157
|
+
assert(result.html.includes('Beta'), `Missing Beta: ${result.html}`)
|
|
158
|
+
assert(result.html.includes('Gamma'), `Missing Gamma: ${result.html}`)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
await test('result.server contains resolved fetcher results', async () => {
|
|
162
|
+
const result = await render(listSpec)
|
|
163
|
+
assert(Array.isArray(result.server.items), 'Expected items array')
|
|
164
|
+
assertEqual(result.server.items.length, 3)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
console.log('\nresult.text()\n')
|
|
170
|
+
|
|
171
|
+
await test('returns all text content with tags stripped', async () => {
|
|
172
|
+
const result = renderSync(counterSpec, { state: { count: 5 } })
|
|
173
|
+
const text = result.text()
|
|
174
|
+
assertEqual(text, '5')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
await test('collapses whitespace and joins text from multiple elements', async () => {
|
|
178
|
+
const result = renderSync(formSpec)
|
|
179
|
+
// Buttons have text "Send"
|
|
180
|
+
assert(result.text().includes('Send'), `Expected Send in text: ${result.text()}`)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
console.log('\nresult.has(selector)\n')
|
|
186
|
+
|
|
187
|
+
await test('returns true when element exists', async () => {
|
|
188
|
+
const result = renderSync(counterSpec)
|
|
189
|
+
assert(result.has('div'), 'Expected div to exist')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
await test('returns true for class selector', async () => {
|
|
193
|
+
const result = renderSync(counterSpec)
|
|
194
|
+
assert(result.has('.counter'), 'Expected .counter to exist')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
await test('returns false when element does not exist', async () => {
|
|
198
|
+
const result = renderSync(counterSpec)
|
|
199
|
+
assert(!result.has('table'), 'Expected no table')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
await test('matches attribute selector [attr]', async () => {
|
|
203
|
+
const result = renderSync(formSpec)
|
|
204
|
+
assert(result.has('[required]'), 'Expected [required] to exist')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
await test('matches attribute selector [attr="value"]', async () => {
|
|
208
|
+
const result = renderSync(formSpec)
|
|
209
|
+
assert(result.has('input[type="email"]'), 'Expected email input')
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
await test('matches attribute selector [attr="value"] (negative)', async () => {
|
|
213
|
+
const result = renderSync(formSpec)
|
|
214
|
+
assert(!result.has('input[type="password"]'), 'Expected no password input')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
console.log('\nresult.find() and result.get()\n')
|
|
220
|
+
|
|
221
|
+
await test('find() returns the first matching element', async () => {
|
|
222
|
+
const result = renderSync(counterSpec)
|
|
223
|
+
const el = result.find('span')
|
|
224
|
+
assert(el !== null, 'Expected span element')
|
|
225
|
+
assertEqual(el.tag, 'span')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
await test('find() returns null when not found', async () => {
|
|
229
|
+
const result = renderSync(counterSpec)
|
|
230
|
+
const el = result.find('table')
|
|
231
|
+
assertEqual(el, null)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
await test('get() returns the element when found', async () => {
|
|
235
|
+
const result = renderSync(counterSpec)
|
|
236
|
+
const el = result.get('.counter')
|
|
237
|
+
assert(el !== null, 'Expected .counter element')
|
|
238
|
+
assertEqual(el.tag, 'div')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
await test('get() throws when element not found', async () => {
|
|
242
|
+
const result = renderSync(counterSpec)
|
|
243
|
+
let threw = false
|
|
244
|
+
try { result.get('table') } catch (e) {
|
|
245
|
+
threw = true
|
|
246
|
+
assert(e.message.includes('table'), `Expected selector in error: ${e.message}`)
|
|
247
|
+
}
|
|
248
|
+
assert(threw, 'Expected get() to throw')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
await test('find() by id selector', async () => {
|
|
252
|
+
const result = renderSync(counterSpec, { state: { count: 3 } })
|
|
253
|
+
const el = result.find('#count')
|
|
254
|
+
assert(el !== null, 'Expected #count element')
|
|
255
|
+
assertEqual(el.text, '3')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
await test('find() by compound selector (tag + class)', async () => {
|
|
259
|
+
const result = renderSync(counterSpec)
|
|
260
|
+
const el = result.find('div.counter')
|
|
261
|
+
assert(el !== null, 'Expected div.counter')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
await test('find() by attribute selector', async () => {
|
|
265
|
+
const result = await render(productSpec, { server: { product: { name: 'W', price: 1 } } })
|
|
266
|
+
const el = result.find('[disabled]')
|
|
267
|
+
assert(el !== null, 'Expected disabled button')
|
|
268
|
+
assertEqual(el.tag, 'button')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
console.log('\nresult.findAll()\n')
|
|
274
|
+
|
|
275
|
+
await test('findAll() returns all matching elements', async () => {
|
|
276
|
+
const result = await render(listSpec)
|
|
277
|
+
const items = result.findAll('li')
|
|
278
|
+
assertEqual(items.length, 3)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
await test('findAll() returns empty array when nothing matches', async () => {
|
|
282
|
+
const result = renderSync(counterSpec)
|
|
283
|
+
const items = result.findAll('table')
|
|
284
|
+
assertEqual(items.length, 0)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
console.log('\nresult.count()\n')
|
|
290
|
+
|
|
291
|
+
await test('count() returns the number of matching elements', async () => {
|
|
292
|
+
const result = renderSync(formSpec)
|
|
293
|
+
assertEqual(result.count('input'), 2)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
await test('count() returns 0 when nothing matches', async () => {
|
|
297
|
+
const result = renderSync(counterSpec)
|
|
298
|
+
assertEqual(result.count('table'), 0)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
console.log('\nresult.attr()\n')
|
|
304
|
+
|
|
305
|
+
await test('attr() returns attribute value', async () => {
|
|
306
|
+
const result = renderSync(formSpec)
|
|
307
|
+
assertEqual(result.attr('input[name="email"]', 'type'), 'email')
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
await test('attr() returns empty string for boolean attributes', async () => {
|
|
311
|
+
const result = await render(productSpec, { server: { product: { name: 'W', price: 1 } } })
|
|
312
|
+
assertEqual(result.attr('button[disabled]', 'disabled'), '')
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
await test('attr() returns null when element not found', async () => {
|
|
316
|
+
const result = renderSync(counterSpec)
|
|
317
|
+
assertEqual(result.attr('table', 'class'), null)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
await test('attr() returns null when attribute not present', async () => {
|
|
321
|
+
const result = renderSync(counterSpec)
|
|
322
|
+
assertEqual(result.attr('span', 'data-missing'), null)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
console.log('\nElement methods\n')
|
|
328
|
+
|
|
329
|
+
await test('element.find() searches within the element', async () => {
|
|
330
|
+
const result = renderSync(counterSpec)
|
|
331
|
+
const container = result.get('.counter')
|
|
332
|
+
const span = container.find('span')
|
|
333
|
+
assert(span !== null, 'Expected span inside .counter')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
await test('element.find() does not match outside the element', async () => {
|
|
337
|
+
const spec = {
|
|
338
|
+
route: '/test',
|
|
339
|
+
state: {},
|
|
340
|
+
view: () => `
|
|
341
|
+
<section id="a"><p class="target">A</p></section>
|
|
342
|
+
<section id="b"><p class="other">B</p></section>
|
|
343
|
+
`,
|
|
344
|
+
}
|
|
345
|
+
const result = renderSync(spec)
|
|
346
|
+
const a = result.get('#a')
|
|
347
|
+
assert(a.has('.target'), 'Expected .target in #a')
|
|
348
|
+
assert(!a.has('.other'), 'Expected .other to be outside #a')
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
await test('element.findAll() returns all descendants', async () => {
|
|
352
|
+
const result = await render(listSpec)
|
|
353
|
+
const ul = result.get('ul')
|
|
354
|
+
assertEqual(ul.findAll('li').length, 3)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
await test('element.text returns inner text content', async () => {
|
|
358
|
+
const result = await render(listSpec)
|
|
359
|
+
const items = result.findAll('li')
|
|
360
|
+
assertEqual(items[0].text, 'Alpha')
|
|
361
|
+
assertEqual(items[1].text, 'Beta')
|
|
362
|
+
assertEqual(items[2].text, 'Gamma')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
await test('element.attr() returns attribute value', async () => {
|
|
366
|
+
const result = renderSync(formSpec)
|
|
367
|
+
const email = result.get('input[type="email"]')
|
|
368
|
+
assertEqual(email.attr('name'), 'email')
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
await test('element.has() checks for descendants', async () => {
|
|
372
|
+
const result = renderSync(formSpec)
|
|
373
|
+
const form = result.get('form')
|
|
374
|
+
assert(form.has('button[type="submit"]'), 'Expected submit button in form')
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
console.log('\nHTML entity handling\n')
|
|
380
|
+
|
|
381
|
+
await test('text() decodes HTML entities', async () => {
|
|
382
|
+
const spec = {
|
|
383
|
+
route: '/esc',
|
|
384
|
+
state: { msg: 'Hello & World' },
|
|
385
|
+
view: (state) => `<p>${state.msg}</p>`,
|
|
386
|
+
}
|
|
387
|
+
const result = renderSync(spec)
|
|
388
|
+
assertEqual(result.get('p').text, 'Hello & World')
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
await test('text() decodes < and >', async () => {
|
|
392
|
+
const spec = {
|
|
393
|
+
route: '/esc2',
|
|
394
|
+
state: {},
|
|
395
|
+
view: () => `<p><code></p>`,
|
|
396
|
+
}
|
|
397
|
+
const result = renderSync(spec)
|
|
398
|
+
assertEqual(result.get('p').text, '<code>')
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
console.log('\nEdge cases\n')
|
|
404
|
+
|
|
405
|
+
await test('handles void elements without closing tag', async () => {
|
|
406
|
+
const spec = {
|
|
407
|
+
route: '/void',
|
|
408
|
+
state: {},
|
|
409
|
+
view: () => `<div><img src="/logo.png" alt="Logo"><input type="text"></div>`,
|
|
410
|
+
}
|
|
411
|
+
const result = renderSync(spec)
|
|
412
|
+
assert(result.has('img'), 'Expected img')
|
|
413
|
+
assert(result.has('input'), 'Expected input')
|
|
414
|
+
assertEqual(result.attr('img', 'src'), '/logo.png')
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
await test('handles nested same-tag elements correctly', async () => {
|
|
418
|
+
const spec = {
|
|
419
|
+
route: '/nested',
|
|
420
|
+
state: {},
|
|
421
|
+
view: () => `<div class="outer"><div class="inner"><span>deep</span></div></div>`,
|
|
422
|
+
}
|
|
423
|
+
const result = renderSync(spec)
|
|
424
|
+
const outer = result.get('.outer')
|
|
425
|
+
const inner = outer.find('.inner')
|
|
426
|
+
assert(inner !== null, 'Expected .inner')
|
|
427
|
+
const span = inner.find('span')
|
|
428
|
+
assert(span !== null, 'Expected span in .inner')
|
|
429
|
+
assertEqual(span.text, 'deep')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
await test('count() handles multiple disjoint elements', async () => {
|
|
433
|
+
const spec = {
|
|
434
|
+
route: '/multi',
|
|
435
|
+
state: {},
|
|
436
|
+
view: () => `<ul><li>A</li><li>B</li><li>C</li></ul>`,
|
|
437
|
+
}
|
|
438
|
+
const result = renderSync(spec)
|
|
439
|
+
assertEqual(result.count('li'), 3)
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
await test('renderSync returns raw html string', async () => {
|
|
443
|
+
const result = renderSync(counterSpec)
|
|
444
|
+
assert(typeof result.html === 'string', 'Expected html to be a string')
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
|
450
|
+
if (failed > 0) process.exit(1)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Accordion
|
|
3
|
+
*
|
|
4
|
+
* Collapsible FAQ-style items using native <details>/<summary> — no JS required.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} opts
|
|
7
|
+
* @param {Array<{question: string, answer: string}>} opts.items
|
|
8
|
+
* @param {string} opts.class
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { escHtml as e } from '../html.js'
|
|
12
|
+
|
|
13
|
+
export function accordion({
|
|
14
|
+
items = [],
|
|
15
|
+
class: cls = '',
|
|
16
|
+
} = {}) {
|
|
17
|
+
const classes = ['ui-accordion', cls].filter(Boolean).join(' ')
|
|
18
|
+
|
|
19
|
+
const itemsHtml = items.map(({ question = '', answer = '' }) => `<details class="ui-accordion-item">
|
|
20
|
+
<summary class="ui-accordion-summary">
|
|
21
|
+
<span>${e(question)}</span>
|
|
22
|
+
<span class="ui-accordion-icon" aria-hidden="true"></span>
|
|
23
|
+
</summary>
|
|
24
|
+
<div class="ui-accordion-body"><p>${e(answer)}</p></div>
|
|
25
|
+
</details>`).join('\n')
|
|
26
|
+
|
|
27
|
+
return `<div class="${e(classes)}">${itemsHtml}</div>`
|
|
28
|
+
}
|
package/src/ui/alert.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Alert
|
|
3
|
+
*
|
|
4
|
+
* Inline feedback message. error/warning use role="alert" (assertive),
|
|
5
|
+
* info/success use role="status" (polite).
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {'info'|'success'|'warning'|'error'} opts.variant
|
|
9
|
+
* @param {string} opts.title - Bold heading (optional)
|
|
10
|
+
* @param {string} opts.content - Message body — HTML string, not escaped
|
|
11
|
+
* @param {string} opts.class
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { escHtml as e } from '../html.js'
|
|
15
|
+
import { iconInfo, iconCheckCircle, iconAlertTriangle, iconXCircle } from './icons.js'
|
|
16
|
+
|
|
17
|
+
const VARIANTS = new Set(['info', 'success', 'warning', 'error'])
|
|
18
|
+
|
|
19
|
+
const ICONS = {
|
|
20
|
+
info: iconInfo({ size: 18 }),
|
|
21
|
+
success: iconCheckCircle({ size: 18 }),
|
|
22
|
+
warning: iconAlertTriangle({ size: 18 }),
|
|
23
|
+
error: iconXCircle({ size: 18 }),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function alert({
|
|
27
|
+
variant = 'info',
|
|
28
|
+
title = '',
|
|
29
|
+
content = '',
|
|
30
|
+
class: cls = '',
|
|
31
|
+
} = {}) {
|
|
32
|
+
if (!VARIANTS.has(variant)) variant = 'info'
|
|
33
|
+
|
|
34
|
+
const role = (variant === 'error' || variant === 'warning') ? 'alert' : 'status'
|
|
35
|
+
const classes = ['ui-alert', `ui-alert--${variant}`, cls].filter(Boolean).join(' ')
|
|
36
|
+
|
|
37
|
+
const titleHtml = title ? `<strong class="ui-alert-title">${e(title)}</strong> ` : ''
|
|
38
|
+
|
|
39
|
+
return `<div class="${e(classes)}" role="${role}">
|
|
40
|
+
<span class="ui-alert-icon">${ICONS[variant]}</span>
|
|
41
|
+
<div class="ui-alert-body">${titleHtml}${content}</div>
|
|
42
|
+
</div>`
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — App Badge
|
|
3
|
+
*
|
|
4
|
+
* App Store and Google Play download buttons.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} opts
|
|
7
|
+
* @param {'apple'|'google'} opts.store - Which badge to render
|
|
8
|
+
* @param {string} opts.href - Link to the app store listing
|
|
9
|
+
* @param {string} opts.class
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { escHtml as e } from '../html.js'
|
|
13
|
+
|
|
14
|
+
const APPLE_ICON = `<svg width="18" height="22" viewBox="0 0 814 1000" fill="currentColor" aria-hidden="true"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76 0-103.7 40.8-165.9 40.8s-105-37.2-148.8-97.2C67.6 772.6 20.4 678.3 20.4 588.3c0-154.8 100.9-236.7 199.6-236.7 74.7 0 136.8 47.4 183.1 47.4 44.4 0 113.9-49.9 197-49.9zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>`
|
|
15
|
+
|
|
16
|
+
const GOOGLE_ICON = `<svg width="18" height="20" viewBox="0 0 512 512" fill="currentColor" aria-hidden="true"><path d="M325.3 234.3L104.6 13l280.8 161.2-60.1 60.1zM47 0C34 6.8 25.3 19.2 25.3 35.3v441.3c0 16.1 8.7 28.5 21.7 35.3l232.6-232.5L47 0zm425.6 225.6l-58.9-34-67.7 68.6 67.7 68.5 60.1-34.3c17.3-9.8 17.3-38.1-.2-48.8zM104.6 499l280.8-161.2-60.1-60.1L104.6 499z"/></svg>`
|
|
17
|
+
|
|
18
|
+
const CONFIGS = {
|
|
19
|
+
apple: {
|
|
20
|
+
icon: APPLE_ICON,
|
|
21
|
+
line1: 'Download on the',
|
|
22
|
+
line2: 'App Store',
|
|
23
|
+
label: 'Download on the App Store',
|
|
24
|
+
},
|
|
25
|
+
google: {
|
|
26
|
+
icon: GOOGLE_ICON,
|
|
27
|
+
line1: 'Get it on',
|
|
28
|
+
line2: 'Google Play',
|
|
29
|
+
label: 'Get it on Google Play',
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function appBadge({
|
|
34
|
+
store = 'apple',
|
|
35
|
+
href = '#',
|
|
36
|
+
class: cls = '',
|
|
37
|
+
} = {}) {
|
|
38
|
+
const cfg = CONFIGS[store] ?? CONFIGS.apple
|
|
39
|
+
const classes = ['ui-app-badge', cls].filter(Boolean).join(' ')
|
|
40
|
+
|
|
41
|
+
return `<a href="${e(href)}" class="${e(classes)}" aria-label="${e(cfg.label)}" target="_blank" rel="noopener noreferrer">
|
|
42
|
+
${cfg.icon}
|
|
43
|
+
<span class="ui-app-badge-text">
|
|
44
|
+
<span class="ui-app-badge-line1">${cfg.line1}</span>
|
|
45
|
+
<span class="ui-app-badge-line2">${cfg.line2}</span>
|
|
46
|
+
</span>
|
|
47
|
+
</a>`
|
|
48
|
+
}
|
package/src/ui/avatar.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Avatar
|
|
3
|
+
*
|
|
4
|
+
* User image or initials fallback. Renders <img> when src is provided,
|
|
5
|
+
* <span> with initials otherwise. Initials are derived from alt text if not
|
|
6
|
+
* explicitly provided.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {string} opts.src - Image URL (optional)
|
|
10
|
+
* @param {string} opts.alt - Alt text / name (used to derive initials)
|
|
11
|
+
* @param {'sm'|'md'|'lg'|'xl'} opts.size
|
|
12
|
+
* @param {string} opts.initials - Override derived initials
|
|
13
|
+
* @param {string} opts.class
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { escHtml as e } from '../html.js'
|
|
17
|
+
|
|
18
|
+
const SIZES = new Set(['sm', 'md', 'lg', 'xl'])
|
|
19
|
+
|
|
20
|
+
function deriveInitials(alt) {
|
|
21
|
+
return (alt || '')
|
|
22
|
+
.trim()
|
|
23
|
+
.split(/\s+/)
|
|
24
|
+
.map(w => w[0] || '')
|
|
25
|
+
.join('')
|
|
26
|
+
.slice(0, 2)
|
|
27
|
+
.toUpperCase() || '?'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function avatar({
|
|
31
|
+
src = '',
|
|
32
|
+
alt = '',
|
|
33
|
+
size = 'md',
|
|
34
|
+
initials = '',
|
|
35
|
+
class: cls = '',
|
|
36
|
+
} = {}) {
|
|
37
|
+
if (!SIZES.has(size)) size = 'md'
|
|
38
|
+
|
|
39
|
+
const classes = ['ui-avatar', `ui-avatar--${size}`, cls].filter(Boolean).join(' ')
|
|
40
|
+
|
|
41
|
+
if (src) {
|
|
42
|
+
return `<img src="${e(src)}" alt="${e(alt)}" class="${e(classes)}" width="40" height="40" loading="lazy" decoding="async">`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const display = initials || deriveInitials(alt)
|
|
46
|
+
return `<span class="${e(classes)}" aria-label="${e(alt || 'Avatar')}" role="img">${e(display)}</span>`
|
|
47
|
+
}
|
package/src/ui/badge.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Badge
|
|
3
|
+
*
|
|
4
|
+
* Inline label for status, category, or count.
|
|
5
|
+
*
|
|
6
|
+
* @param {object} opts
|
|
7
|
+
* @param {string} opts.label
|
|
8
|
+
* @param {'default'|'success'|'warning'|'error'|'info'} opts.variant
|
|
9
|
+
* @param {string} opts.class
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { escHtml as e } from '../html.js'
|
|
13
|
+
|
|
14
|
+
const VARIANTS = new Set(['default', 'success', 'warning', 'error', 'info'])
|
|
15
|
+
|
|
16
|
+
export function badge({
|
|
17
|
+
label = '',
|
|
18
|
+
variant = 'default',
|
|
19
|
+
class: cls = '',
|
|
20
|
+
} = {}) {
|
|
21
|
+
if (!VARIANTS.has(variant)) variant = 'default'
|
|
22
|
+
const classes = ['ui-badge', `ui-badge--${variant}`, cls].filter(Boolean).join(' ')
|
|
23
|
+
return `<span class="${e(classes)}">${e(label)}</span>`
|
|
24
|
+
}
|
package/src/ui/banner.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Banner
|
|
3
|
+
*
|
|
4
|
+
* Full-width announcement bar — sits above the nav for promotions,
|
|
5
|
+
* notices, or launch announcements.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.content - Raw HTML slot
|
|
9
|
+
* @param {'info'|'promo'|'warning'} opts.variant - Visual style (default: 'info')
|
|
10
|
+
* @param {string} opts.class
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { escHtml as e } from '../html.js'
|
|
14
|
+
|
|
15
|
+
const VARIANTS = new Set(['info', 'promo', 'warning'])
|
|
16
|
+
|
|
17
|
+
export function banner({
|
|
18
|
+
content = '',
|
|
19
|
+
variant = 'info',
|
|
20
|
+
class: cls = '',
|
|
21
|
+
} = {}) {
|
|
22
|
+
if (!VARIANTS.has(variant)) variant = 'info'
|
|
23
|
+
const classes = ['ui-banner', `ui-banner--${variant}`, cls].filter(Boolean).join(' ')
|
|
24
|
+
|
|
25
|
+
return `<div class="${e(classes)}" role="banner">${content}</div>`
|
|
26
|
+
}
|