diff --git a/.github/workflows/consumer-smoke.yml b/.github/workflows/consumer-smoke.yml new file mode 100644 index 0000000..e4423f9 --- /dev/null +++ b/.github/workflows/consumer-smoke.yml @@ -0,0 +1,43 @@ +name: Consumer smoke (packed artefacts) + +# Audit №5 root-cause closure : the rc.0/rc.1 npm publish shipped +# stale dist/ files importing the pre-rename @pulse/* scope. Every CI +# suite was green because everything ran on workspace SOURCES — no job +# ever installed a tarball the way a real user does. This workflow +# packs each buildable package (plus the root Vue lib), installs the +# tarballs into throwaway consumer projects, vite-builds an entry that +# imports the public surface, and asserts no stale-scope strings in +# the output bundles. If this is red, DO NOT PUBLISH. + +on: + push: + branches: [main, 'feat/**'] + pull_request: + branches: [main] + +concurrency: + group: consumer-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: Pack → install → build (4 consumers) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + + - name: Setup Node 20 + uses: actions/setup-node@v6 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci --no-audit --prefer-offline + + - name: Build workspace packages + run: npm run build:packages + + - name: Consumer smoke + run: npm run test:consumer diff --git a/NOTICE.md b/NOTICE.md index c0f6030..56a056c 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -78,6 +78,21 @@ Attribution is also displayed in the demo footer ("Music: Kevin MacLeod — inco If the incompetech download fails at deploy time, the pipeline falls back to the in-repo composed tracks (`scripts/synth-demo-tracks.mjs` — original works of this repository, MIT). Maintainer overrides via the `PULSE_TRACK1_URL` / `PULSE_TRACK2_URL` repository variables must keep this section + the footer credit accurate for whatever they point at. +### 3ter. Demo typefaces — self-hosted Geist (round-10, 2026-06-11) + +The demo page (NOT the library) ships two variable typefaces under +`public/fonts/`, self-hosted since round 10 (previously loaded from the +Google Fonts CDN — removed for GDPR + first-paint performance): + +- **Geist** (`Geist-Variable.woff2`) and **Geist Mono** + (`GeistMono-Variable.woff2`) — © The Geist Project Authors + (Vercel), licensed under the **SIL Open Font License 1.1**. + Full licence text shipped alongside at `public/fonts/OFL.txt`. + Source: . + +The OFL permits bundling, redistribution and self-hosting provided the +licence accompanies the fonts — it does here. + ## 4. Trademarks and brand names pulse-player references the following brand names purely for descriptive diff --git a/index.html b/index.html index 4f6b9ed..4f05165 100644 --- a/index.html +++ b/index.html @@ -13,15 +13,38 @@ /> - - - + + @@ -66,7 +89,7 @@ "applicationCategory": "DeveloperApplication", "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }, "license": "https://opensource.org/licenses/MIT", - "softwareVersion": "3.0.0-rc.0", + "softwareVersion": "3.0.0-rc.2", "downloadUrl": "https://www.npmjs.com/package/@pulse-music/react", "video": "https://youtu.be/q_FJ1GWaCc8", "url": "https://yamadablog.github.io/pulse-player/" diff --git a/package.json b/package.json index 451093a..44b023e 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,8 @@ "check:lib-identical": "node scripts/check-lib-byte-identical.mjs", "ci": "npm run type-check && npm run lint && npm run test && npm run test:packages && npm run build && npm run audit && npm run check:lib-identical", "prepublishOnly": "npm run ci && npm run build:lib", - "prepare": "husky 2>/dev/null || true" + "prepare": "husky 2>/dev/null || true", + "test:consumer": "node scripts/consumer-smoke.mjs" }, "peerDependencies": { "pinia": "^2.1.0", diff --git a/packages/core/package.json b/packages/core/package.json index f127f7d..069424f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@pulse-music/core", - "version": "3.0.0-rc.0", + "version": "3.0.0-rc.2", "description": "Framework-agnostic audio engine for pulse-player — Web Audio + state machine + typed event bus. Pure TypeScript, no DOM, no framework imports.", "license": "MIT", "type": "module", @@ -22,7 +22,8 @@ "scripts": { "build": "tsup", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "prepublishOnly": "npm run build" }, "dependencies": { "@pulse-music/types": "*" diff --git a/packages/react/package.json b/packages/react/package.json index 3e67af4..49a761e 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@pulse-music/react", - "version": "3.0.0-rc.0", + "version": "3.0.0-rc.2", "description": "React 18 / 19 wrapper for pulse-player — hooks + JSX components built on @pulse-music/web-component.", "license": "MIT", "type": "module", @@ -22,7 +22,8 @@ "scripts": { "build": "tsup", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "prepublishOnly": "npm run build" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index b053975..66b23e9 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@pulse-music/svelte", - "version": "3.0.0-rc.0", + "version": "3.0.0-rc.2", "description": "Svelte 5 wrapper for pulse-player. Plain TypeScript hook + Svelte classic-store contract over @pulse-music/web-component.", "license": "MIT", "type": "module", @@ -22,7 +22,8 @@ "scripts": { "build": "tsup", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "prepublishOnly": "npm run build" }, "peerDependencies": { "svelte": "^5.56.3" diff --git a/packages/tokens/package.json b/packages/tokens/package.json index b4453e3..2cf5e46 100644 --- a/packages/tokens/package.json +++ b/packages/tokens/package.json @@ -29,7 +29,8 @@ "scripts": { "build": "tsup", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "prepublishOnly": "npm run build" }, "publishConfig": { "access": "public" diff --git a/packages/types/package.json b/packages/types/package.json index 3ca4182..fc882af 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -20,7 +20,8 @@ "README.md" ], "scripts": { - "build": "tsup" + "build": "tsup", + "prepublishOnly": "npm run build" }, "publishConfig": { "access": "public" diff --git a/packages/web-component/package.json b/packages/web-component/package.json index 8d844c8..702cb31 100644 --- a/packages/web-component/package.json +++ b/packages/web-component/package.json @@ -1,6 +1,6 @@ { "name": "@pulse-music/web-component", - "version": "3.0.0-rc.1", + "version": "3.0.0-rc.2", "description": "Universal Web Component renderer for pulse-player — Lit-based `` and ``. Works in React, Vue, Angular, Svelte, Solid, vanilla JS, or plain HTML.", "license": "MIT", "type": "module", @@ -22,7 +22,8 @@ "scripts": { "build": "tsup", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "prepublishOnly": "npm run build" }, "dependencies": { "@pulse-music/core": "*", diff --git a/packages/web-component/src/PulseFab.ts b/packages/web-component/src/PulseFab.ts index f718381..4c1302a 100644 --- a/packages/web-component/src/PulseFab.ts +++ b/packages/web-component/src/PulseFab.ts @@ -22,11 +22,11 @@ import { baseStyles, fabStyles } from './styles' * Events: same as `` (events bubble + compose, so a * single parent listener covers both elements). * - * Status: v3.0.0-alpha.2 SKELETON. Renders the disc + play/pause + - * variant. Drag-to-reposition, radial menu, pulso heartbeat - * keyframes land in subsequent alphas. The Lit controller pattern is - * in place — adding drag is additive logic, not architectural - * refactor. + * Status: v3.0.0-rc — feature-complete (round-10 docstring refresh; + * the "SKELETON" note dated from alpha.2 and was stale). Renders the + * disc + play/pause + variant, with drag-to-reposition (`draggable`, + * persisted to localStorage), the radial menu (`show-menu`) and the + * pulso heartbeat ring all implemented below. */ @customElement('pulse-fab') export class PulseFabElement extends LitElement { diff --git a/packages/web-component/src/PulsePlayer.ts b/packages/web-component/src/PulsePlayer.ts index ebdbaad..354bc52 100644 --- a/packages/web-component/src/PulsePlayer.ts +++ b/packages/web-component/src/PulsePlayer.ts @@ -31,12 +31,13 @@ import { GITHUB_SVG, STREAM_SVG } from './icons' * - `accent-color` : CSS color (default unset — inherits theme) * - `tracks` : JSON array of Track objects (optional override) * - * Status: v3.0.0-alpha.2 SKELETON. Renders the minimum chrome - * (artwork, title, play/pause, progress, time). Full v2.3.4 parity - * (ambient EQ, drag-to-resize, three responsive states, social icons, - * `prev` / `next` controls) lands in subsequent alphas. The Lit - * controller pattern is already in place — adding more chrome is - * additive markup, not architectural refactor. + * Status: v3.0.0-rc — feature-complete chrome (round-10 docstring + * refresh; the "SKELETON" note below dated from alpha.2 and was stale). + * Renders artwork, title, play/pause, `prev`/`next`, progress, time, + * drag-to-resize handle, three responsive width states, and optional + * GitHub/Spotify social icons. Remaining gap vs the Vue v2.3.4 + * reference: the 64-bar ambient EQ backdrop (the 4-bar condensed + * visualiser is wired via the engine's `eqBars`). */ @customElement('pulse-player') export class PulsePlayerElement extends LitElement { diff --git a/public/fonts/Geist-Variable.woff2 b/public/fonts/Geist-Variable.woff2 new file mode 100644 index 0000000..71dab8b Binary files /dev/null and b/public/fonts/Geist-Variable.woff2 differ diff --git a/public/fonts/GeistMono-Variable.woff2 b/public/fonts/GeistMono-Variable.woff2 new file mode 100644 index 0000000..0e746fc Binary files /dev/null and b/public/fonts/GeistMono-Variable.woff2 differ diff --git a/public/fonts/OFL.txt b/public/fonts/OFL.txt new file mode 100644 index 0000000..04e95fc --- /dev/null +++ b/public/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/scripts/consumer-smoke.mjs b/scripts/consumer-smoke.mjs new file mode 100644 index 0000000..18d9365 --- /dev/null +++ b/scripts/consumer-smoke.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +/** + * consumer-smoke.mjs — prove the PACKED artefacts work for a real + * consumer, not just the workspace sources. + * + * Audit №5 root-cause : the rc.0/rc.1 npm publish shipped dist/ files + * built BEFORE the @pulse/* → @pulse-music/* scope rename. Every + * repo test passed (they run on sources via workspaces) while every + * actual `npm install @pulse-music/web-component && vite build` + * failed with "Rollup failed to resolve import '@pulse/core'". + * Nothing in CI consumed a tarball — this script closes that hole. + * + * What it does, per buildable package + the root Vue lib : + * 1. `npm pack` the package (after the workspace build). + * 2. Scaffold a THROWAWAY consumer project in a temp dir + * (real package.json, no workspace links). + * 3. Install the tarball(s) from disk. + * 4. Vite-build an entry that imports the public surface. + * 5. Assert the bundle contains no stale-scope imports. + * + * Exit codes : 0 = all consumers build ; 1 = a consumer failed ; + * the failing package and the build output are printed. + * + * Usage : node scripts/consumer-smoke.mjs + * (CI: .github/workflows/consumer-smoke.yml) + */ + +import { execSync } from 'node:child_process' +import { mkdtempSync, writeFileSync, mkdirSync, readdirSync, rmSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = join(fileURLToPath(import.meta.url), '..', '..') +const sh = (cmd, cwd) => execSync(cmd, { cwd, stdio: 'pipe', encoding: 'utf-8' }) + +/** Pack a workspace package, return absolute tarball path. */ +function pack(dir) { + const out = sh('npm pack --silent', dir).trim().split('\n').pop() + return join(dir, out) +} + +function scaffold(name) { + const dir = mkdtempSync(join(tmpdir(), `pulse-smoke-${name}-`)) + mkdirSync(join(dir, 'src'), { recursive: true }) + return dir +} + +function buildConsumer(dir, entryCode, deps, extraPkg = {}) { + writeFileSync( + join(dir, 'package.json'), + JSON.stringify( + { + name: 'smoke', + private: true, + type: 'module', + scripts: { build: 'vite build' }, + devDependencies: { vite: '^5.0.0', ...(extraPkg.devDependencies ?? {}) }, + dependencies: extraPkg.dependencies ?? {}, + }, + null, + 2, + ), + ) + writeFileSync(join(dir, 'index.html'), '
') + writeFileSync(join(dir, 'src/main.js'), entryCode) + if (extraPkg.files) for (const [p, c] of Object.entries(extraPkg.files)) writeFileSync(join(dir, p), c) + sh('npm install --no-audit --silent --fund=false', dir) + if (deps.length) sh(`npm install --no-audit --silent --fund=false ${deps.map((d) => JSON.stringify(d)).join(' ')}`, dir) + sh('npm run build', dir) + // Stale-scope assertion on the produced bundle. + const assets = join(dir, 'dist', 'assets') + for (const f of readdirSync(assets)) { + const body = readFileSync(join(assets, f), 'utf-8') + if (/@pulse\//.test(body)) throw new Error(`stale @pulse/ scope found in built ${f}`) + } +} + +const failures = [] + +function run(name, fn) { + process.stdout.write(`▶ consumer: ${name} … `) + try { + fn() + console.log('✅') + } catch (err) { + console.log('❌') + failures.push({ name, err: String(err.stdout || err.message || err).slice(-2000) }) + } +} + +// ─── 1. Web Component chain (types + tokens + core + web-component) ── +run('web-component (vanilla)', () => { + const tarballs = ['types', 'tokens', 'core', 'web-component'].map((p) => pack(join(ROOT, 'packages', p))) + const dir = scaffold('wc') + buildConsumer(dir, `import '@pulse-music/web-component'\nif(!customElements.get('pulse-player')) throw new Error('element not registered')\n`, tarballs) + rmSync(dir, { recursive: true, force: true }) +}) + +// ─── 2. React wrapper chain ────────────────────────────────────────── +run('react wrapper', () => { + const tarballs = ['types', 'tokens', 'core', 'web-component', 'react'].map((p) => pack(join(ROOT, 'packages', p))) + const dir = scaffold('react') + buildConsumer( + dir, + `import { PulsePlayer } from '@pulse-music/react'\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\ncreateRoot(document.getElementById('app')).render(React.createElement(PulsePlayer, { variant: 'aurora' }))\n`, + tarballs, + { dependencies: { react: '^18.3.1', 'react-dom': '^18.3.1' } }, + ) + rmSync(dir, { recursive: true, force: true }) +}) + +// ─── 3. Svelte wrapper chain (plain TS hook — no compiler needed) ──── +run('svelte wrapper', () => { + const tarballs = ['types', 'tokens', 'core', 'web-component', 'svelte'].map((p) => pack(join(ROOT, 'packages', p))) + const dir = scaffold('svelte') + buildConsumer(dir, `import { usePulseAudio, ALL_VARIANTS } from '@pulse-music/svelte'\nconsole.log(typeof usePulseAudio, ALL_VARIANTS.length)\n`, tarballs) + rmSync(dir, { recursive: true, force: true }) +}) + +// ─── 4. Root Vue library tarball ───────────────────────────────────── +run('vue root lib', () => { + sh('npm run build:lib', ROOT) + const tarball = pack(ROOT) + const dir = scaffold('vue') + buildConsumer( + dir, + `import { createApp, h } from 'vue'\nimport { createPinia } from 'pinia'\nimport { MusicPlayer, setAudioTracks } from 'pulse-player'\nimport 'pulse-player/style.css'\nsetAudioTracks([{ title: 'T', src: '/x.webm', cover: '/x.webp', coverPos: '50% 50%' }])\ncreateApp({ render: () => h(MusicPlayer, { variant: 'midnight' }) }).use(createPinia()).mount('#app')\n`, + [tarball], + { + dependencies: { vue: '^3.4.0', pinia: '^2.1.0' }, + devDependencies: { '@vitejs/plugin-vue': '^5.0.0' }, + files: { + 'vite.config.js': `import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\nexport default defineConfig({ plugins: [vue()] })\n`, + }, + }, + ) + rmSync(dir, { recursive: true, force: true }) + rmSync(tarball, { force: true }) +}) + +// Clean package tarballs left in workspace dirs. +for (const p of ['types', 'tokens', 'core', 'web-component', 'react', 'svelte']) { + const dir = join(ROOT, 'packages', p) + for (const f of readdirSync(dir)) if (f.endsWith('.tgz')) rmSync(join(dir, f), { force: true }) +} + +if (failures.length) { + console.error(`\n✗ ${failures.length} consumer build(s) FAILED :`) + for (const f of failures) console.error(`\n── ${f.name} ──\n${f.err}`) + process.exit(1) +} +console.log('\n✅ every packed artefact builds in a clean consumer project.') diff --git a/src/App.vue b/src/App.vue index bb04797..537e5e3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,5 @@