diff --git a/.gitignore b/.gitignore
index 269f090..c2facee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,5 @@
.figma-token
playwright-report/
test-results/
+tests/figma-diffs/
+tests/figma-screenshots/
diff --git a/CLAUDE.md b/CLAUDE.md
index 4fc1fbe..8b1a815 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -55,7 +55,13 @@
## Common commands
-No build, lint, test, or development commands are defined yet because the project has no `package.json` or tooling configuration. Once the build stack is chosen, update this section with the actual commands.
+- `npm run build` — production build (styles, scripts, Vue adapter, demo HTML).
+- `npm run dev` / `npm start` — BrowserSync dev server on `http://localhost:3000`.
+- `npm run test:e2e` — run the Playwright test suite.
+- `npx playwright test tests/figma-match.spec.js` — run Figma visual-regression comparisons.
+- `npm run test:vue-adapter` — build the Vue adapter and the example Vue consumer app.
+
+Figma frames exported to `figma design parts/frames/*.png` are the visual source of truth. Baselines live in `tests/figma-baselines/` and are compared against `demo/figma/*.html` screenshots in `tests/figma-match.spec.js`.
## Verification
diff --git a/demo/figma/alerts.html b/demo/figma/alerts.html
new file mode 100644
index 0000000..8ff4ff7
--- /dev/null
+++ b/demo/figma/alerts.html
@@ -0,0 +1,92 @@
+
+
+
+
diff --git a/demo/partials/divider.html b/demo/partials/divider.html
new file mode 100644
index 0000000..7bfea68
--- /dev/null
+++ b/demo/partials/divider.html
@@ -0,0 +1,14 @@
+
+ Divider
+
+ Default horizontal divider:
+
+
+ Strong variant:
+
+
+ Vertical divider:
+ Before
+
+ After
+
diff --git a/demo/partials/file-upload.html b/demo/partials/file-upload.html
new file mode 100644
index 0000000..ea350ff
--- /dev/null
+++ b/demo/partials/file-upload.html
@@ -0,0 +1,74 @@
+
+ File Upload & Attachment
+
+
+
+
+
+
+
+
+ Choose files
+ Images get thumbnails, other files show their type.
+
+
+
+
+
+
+ File attachment cards
+
+
+
+ PDF
+
+ Quarterly report.pdf
+ PDF / 1.2 MB
+
+
+
+
+
+
+
+ Preview loading...
+
+
+
+
+
+
+
+ Upload failed.docx
+ DOCX / 0 B
+
+
+
+
+
+
+
+ Retry archive.zip
+ ZIP / 4.5 MB
+
+
+
+
diff --git a/demo/partials/stepper.html b/demo/partials/stepper.html
new file mode 100644
index 0000000..e7cde95
--- /dev/null
+++ b/demo/partials/stepper.html
@@ -0,0 +1,44 @@
+
+ Stepper
+
+
+
+
+ Cart
+ Items added and validated.
+
+
+ 2
+ Shipping
+ Choose a delivery address.
+
+
+ 3
+ Payment
+ Enter your payment details.
+
+
+ 4
+ Review
+ Confirm and place the order.
+
+
+
+ Vertical
+
+
+
+ Upload
+ File added.
+
+
+ 2
+ Process
+ Running conversion.
+
+
+ 3
+ Done
+
+
+
diff --git a/figma design parts/frames/Button.cropped.png b/figma design parts/frames/Button.cropped.png
new file mode 100644
index 0000000..f4a1f2b
--- /dev/null
+++ b/figma design parts/frames/Button.cropped.png
Binary files differ
diff --git a/gulpfile.js b/gulpfile.js
index 82972be..90129da 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -28,7 +28,7 @@
},
html: {
watch: "demo/**/*.html",
- entry: "demo/*.html",
+ entry: "demo/**/*.html",
dest: "dist"
},
assets: {
diff --git a/package-lock.json b/package-lock.json
index 5e82dd2..cf471fd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,8 +16,11 @@
"esbuild": "^0.25.6",
"gulp": "^5.0.1",
"gulp-file-include": "^2.3.0",
+ "pixelmatch": "^7.2.0",
+ "pngjs": "^7.0.0",
"postcss": "^8.5.9",
- "sass": "^1.68.0"
+ "sass": "^1.68.0",
+ "sharp": "^0.35.3"
},
"peerDependencies": {
"vue": "^3.4.0"
@@ -28,6 +31,17 @@
}
}
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -493,6 +507,581 @@
"node": ">=10.13.0"
}
},
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
+ "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
+ "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
+ "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
+ "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
+ "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
+ "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
+ "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
+ "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
+ "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
+ "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
+ "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
+ "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
+ "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
+ "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
+ "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
+ "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
+ "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
+ "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
+ "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
+ "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
+ "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
+ "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
+ "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
+ "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
+ "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
+ "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
"node_modules/@parcel/watcher": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz",
@@ -1729,7 +2318,6 @@
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
- "optional": true,
"engines": {
"node": ">=8"
}
@@ -3309,6 +3897,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pixelmatch": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz",
+ "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "pngjs": "^7.0.0"
+ },
+ "bin": {
+ "pixelmatch": "bin/pixelmatch"
+ }
+ },
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
@@ -3372,6 +3973,16 @@
"node": ">= 0.10"
}
},
+ "node_modules/pngjs": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
+ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.19.0"
+ }
+ },
"node_modules/portscanner": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/portscanner/-/portscanner-2.2.0.tgz",
@@ -3896,6 +4507,69 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/sharp": {
+ "version": "0.35.3",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
+ "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.3",
+ "@img/sharp-darwin-x64": "0.35.3",
+ "@img/sharp-freebsd-wasm32": "0.35.3",
+ "@img/sharp-libvips-darwin-arm64": "1.3.2",
+ "@img/sharp-libvips-darwin-x64": "1.3.2",
+ "@img/sharp-libvips-linux-arm": "1.3.2",
+ "@img/sharp-libvips-linux-arm64": "1.3.2",
+ "@img/sharp-libvips-linux-ppc64": "1.3.2",
+ "@img/sharp-libvips-linux-riscv64": "1.3.2",
+ "@img/sharp-libvips-linux-s390x": "1.3.2",
+ "@img/sharp-libvips-linux-x64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
+ "@img/sharp-linux-arm": "0.35.3",
+ "@img/sharp-linux-arm64": "0.35.3",
+ "@img/sharp-linux-ppc64": "0.35.3",
+ "@img/sharp-linux-riscv64": "0.35.3",
+ "@img/sharp-linux-s390x": "0.35.3",
+ "@img/sharp-linux-x64": "0.35.3",
+ "@img/sharp-linuxmusl-arm64": "0.35.3",
+ "@img/sharp-linuxmusl-x64": "0.35.3",
+ "@img/sharp-webcontainers-wasm32": "0.35.3",
+ "@img/sharp-win32-arm64": "0.35.3",
+ "@img/sharp-win32-ia32": "0.35.3",
+ "@img/sharp-win32-x64": "0.35.3"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/sharp/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
@@ -4282,6 +4956,14 @@
"node": ">=0.6"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
diff --git a/package.json b/package.json
index fb1e020..0740655 100644
--- a/package.json
+++ b/package.json
@@ -58,7 +58,10 @@
"esbuild": "^0.25.6",
"gulp": "^5.0.1",
"gulp-file-include": "^2.3.0",
+ "pixelmatch": "^7.2.0",
+ "pngjs": "^7.0.0",
"postcss": "^8.5.9",
- "sass": "^1.68.0"
+ "sass": "^1.68.0",
+ "sharp": "^0.35.3"
}
}
diff --git a/public/assets/backgrounds/gradient-desktop.svg b/public/assets/backgrounds/gradient-desktop.svg
new file mode 100644
index 0000000..29783f8
--- /dev/null
+++ b/public/assets/backgrounds/gradient-desktop.svg
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/backgrounds/gradient-mobile.svg b/public/assets/backgrounds/gradient-mobile.svg
new file mode 100644
index 0000000..c032b87
--- /dev/null
+++ b/public/assets/backgrounds/gradient-mobile.svg
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/backgrounds/gradient-tablet.svg b/public/assets/backgrounds/gradient-tablet.svg
new file mode 100644
index 0000000..5f07836
--- /dev/null
+++ b/public/assets/backgrounds/gradient-tablet.svg
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/backgrounds/shape-desktop.svg b/public/assets/backgrounds/shape-desktop.svg
new file mode 100644
index 0000000..1a6ae94
--- /dev/null
+++ b/public/assets/backgrounds/shape-desktop.svg
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/backgrounds/shape-mobile.svg b/public/assets/backgrounds/shape-mobile.svg
new file mode 100644
index 0000000..c4586ec
--- /dev/null
+++ b/public/assets/backgrounds/shape-mobile.svg
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/assets/backgrounds/shape-tablet.svg b/public/assets/backgrounds/shape-tablet.svg
new file mode 100644
index 0000000..75067ce
--- /dev/null
+++ b/public/assets/backgrounds/shape-tablet.svg
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/compare-with-figma.js b/scripts/compare-with-figma.js
new file mode 100644
index 0000000..1e57715
--- /dev/null
+++ b/scripts/compare-with-figma.js
@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+/**
+ * Standalone script to compare a rendered demo screenshot with a Figma PNG.
+ *
+ * Usage:
+ * node scripts/compare-with-figma.js
[out-diff.png]
+ */
+
+const fs = require("fs");
+const path = require("path");
+const PNG = require("pngjs").PNG;
+const pixelmatch = require("pixelmatch").default || require("pixelmatch");
+const sharp = require("sharp");
+
+async function main() {
+ const [screenshotPath, framePath, diffPathArg] = process.argv.slice(2);
+ if (!screenshotPath || !framePath) {
+ console.error("Usage: node scripts/compare-with-figma.js [out-diff.png]");
+ process.exit(1);
+ }
+
+ const diffPath = diffPathArg || path.join(path.dirname(screenshotPath), `diff-${path.basename(screenshotPath)}`);
+
+ const screenshot = await sharp(screenshotPath)
+ .ensureAlpha()
+ .raw()
+ .toBuffer({ resolveWithObject: true });
+
+ const frame = await sharp(framePath)
+ .ensureAlpha()
+ .resize(screenshot.info.width, screenshot.info.height, { fit: "fill" })
+ .raw()
+ .toBuffer({ resolveWithObject: true });
+
+ const diff = Buffer.alloc(screenshot.info.width * screenshot.info.height * 4);
+ const mismatched = pixelmatch(
+ screenshot.data,
+ frame.data,
+ diff,
+ screenshot.info.width,
+ screenshot.info.height,
+ { threshold: 0.1, includeAA: false }
+ );
+
+ await sharp(diff, {
+ raw: { width: screenshot.info.width, height: screenshot.info.height, channels: 4 }
+ }).png().toFile(diffPath);
+
+ const total = screenshot.info.width * screenshot.info.height;
+ console.log(`Pixels: ${total}`);
+ console.log(`Mismatched: ${mismatched}`);
+ console.log(`Ratio: ${(mismatched / total * 100).toFixed(2)}%`);
+ console.log(`Diff saved to: ${diffPath}`);
+}
+
+main().catch(err => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/list-mode1-tokens.js b/scripts/list-mode1-tokens.js
new file mode 100644
index 0000000..0369a36
--- /dev/null
+++ b/scripts/list-mode1-tokens.js
@@ -0,0 +1,29 @@
+const fs = require("fs");
+const path = require("path");
+
+const file = process.argv[2] || path.join(__dirname, "..", "figma design parts", "vars", "Mode 1.tokens.json");
+const tokens = JSON.parse(fs.readFileSync(file, "utf8"));
+
+function flatten(obj, prefix = "") {
+ const out = [];
+ for (const [key, value] of Object.entries(obj)) {
+ const full = prefix ? `${prefix}/${key}` : key;
+ if (value && typeof value === "object") {
+ if (value["$type"] && value["$value"] !== undefined) {
+ out.push({ name: full, type: value["$type"], value: value["$value"] });
+ } else {
+ out.push(...flatten(value, full));
+ }
+ }
+ }
+ return out;
+}
+
+const flat = flatten(tokens);
+flat.sort((a, b) => a.name.localeCompare(b.name));
+
+for (const t of flat) {
+ let displayValue = t.value;
+ if (typeof displayValue === "object" && displayValue.hex) displayValue = displayValue.hex;
+ console.log(`${t.name}: ${displayValue}`);
+}
diff --git a/src/js/components/accordion.js b/src/js/components/accordion.js
new file mode 100644
index 0000000..0a3b610
--- /dev/null
+++ b/src/js/components/accordion.js
@@ -0,0 +1,125 @@
+const initializedRoots = new WeakSet();
+
+function getPanel(details) {
+ return details.querySelector(".accordion-panel");
+}
+
+function prepareOpenPanel(details) {
+ const panel = getPanel(details);
+
+ if(!panel) {
+ return;
+ }
+
+ panel.style.height = "auto";
+ panel.style.opacity = "1";
+ panel.style.transform = "translateY(0)";
+}
+
+function expand(details) {
+ const panel = getPanel(details);
+
+ if(!panel || details.dataset.animating === "true") {
+ return;
+ }
+
+ details.dataset.animating = "true";
+ details.open = true;
+ panel.style.height = "0px";
+ panel.style.opacity = "0";
+ panel.style.transform = "translateY(-8px)";
+
+ requestAnimationFrame(() => {
+ panel.style.height = `${panel.scrollHeight}px`;
+ panel.style.opacity = "1";
+ panel.style.transform = "translateY(0)";
+ });
+
+ const handleTransitionEnd = event => {
+ if(event.propertyName !== "height") {
+ return;
+ }
+
+ panel.removeEventListener("transitionend", handleTransitionEnd);
+ panel.style.height = "auto";
+ delete details.dataset.animating;
+ };
+
+ panel.addEventListener("transitionend", handleTransitionEnd);
+}
+
+function collapse(details) {
+ const panel = getPanel(details);
+
+ if(!panel || details.dataset.animating === "true") {
+ return;
+ }
+
+ details.dataset.animating = "true";
+ panel.style.height = `${panel.scrollHeight}px`;
+ panel.style.opacity = "1";
+ panel.style.transform = "translateY(0)";
+
+ requestAnimationFrame(() => {
+ panel.style.height = "0px";
+ panel.style.opacity = "0";
+ panel.style.transform = "translateY(-8px)";
+ });
+
+ const handleTransitionEnd = event => {
+ if(event.propertyName !== "height") {
+ return;
+ }
+
+ panel.removeEventListener("transitionend", handleTransitionEnd);
+ details.open = false;
+ panel.style.height = "";
+ panel.style.opacity = "";
+ panel.style.transform = "";
+ delete details.dataset.animating;
+ };
+
+ panel.addEventListener("transitionend", handleTransitionEnd);
+}
+
+function toggle(details) {
+ if(details.open) {
+ collapse(details);
+ } else {
+ expand(details);
+ }
+}
+
+function init(root = document) {
+ if(initializedRoots.has(root)) {
+ return;
+ }
+
+ root.querySelectorAll(".accordion-item[open], .disclosure[open]").forEach(prepareOpenPanel);
+
+ root.addEventListener("click", event => {
+ const summary = event.target.closest(".accordion-summary");
+
+ if(!summary) {
+ return;
+ }
+
+ const details = summary.closest(".accordion-item, .disclosure");
+
+ if(!details) {
+ return;
+ }
+
+ event.preventDefault();
+ toggle(details);
+ });
+
+ initializedRoots.add(root);
+}
+
+export default {
+ init,
+ expand,
+ collapse,
+ toggle
+};
diff --git a/src/js/components/backgrounds.js b/src/js/components/backgrounds.js
new file mode 100644
index 0000000..931d386
--- /dev/null
+++ b/src/js/components/backgrounds.js
@@ -0,0 +1,47 @@
+/**
+ * Background browser helper.
+ * Renders the Figma page backgrounds as CSS background-image layers.
+ */
+
+const VALID_VARIANTS = ["gradient", "shape"];
+const VALID_DEVICES = ["desktop", "tablet", "mobile"];
+
+function className(variant, device, { fixed = false, preview = false } = {}) {
+ const classes = ["vmk-bg"];
+ if (VALID_VARIANTS.includes(variant)) classes.push(`vmk-bg-${variant}`);
+ if (fixed) classes.push("is-fixed");
+ if (preview) classes.push("is-preview");
+ return classes.join(" ");
+}
+
+function html(options = {}) {
+ const { variant = "gradient", device = "desktop", fixed = false, preview = false, content = "" } = options;
+ return `${content}
`;
+}
+
+function create(options = {}) {
+ const { variant = "gradient", device = "desktop", fixed = false, preview = false } = options;
+ const el = document.createElement("div");
+ el.className = className(variant, device, { fixed, preview });
+ el.dataset.variant = VALID_VARIANTS.includes(variant) ? variant : "gradient";
+ el.dataset.device = VALID_DEVICES.includes(device) ? device : "desktop";
+ return el;
+}
+
+function apply(el, options = {}) {
+ if (!el) return null;
+ const { variant = "gradient", device = "desktop", fixed = false, preview = false } = options;
+ el.className = className(variant, device, { fixed, preview });
+ el.dataset.variant = VALID_VARIANTS.includes(variant) ? variant : "gradient";
+ el.dataset.device = VALID_DEVICES.includes(device) ? device : "desktop";
+ return el;
+}
+
+const Background = { html, create, apply, className };
+
+export default Background;
+export { html, create, apply, className };
diff --git a/src/js/components/breadcrumbs.js b/src/js/components/breadcrumbs.js
new file mode 100644
index 0000000..32328f8
--- /dev/null
+++ b/src/js/components/breadcrumbs.js
@@ -0,0 +1,55 @@
+/**
+ * Breadcrumbs browser helper.
+ */
+
+const VALID_MODIFIERS = ["inverted", "sm", "lg"];
+
+function itemHtml(label, { href = "", active = false, hover = false } = {}) {
+ const tag = href ? "a" : "span";
+ const attrs = href ? `href="${href}"` : "";
+ const classes = active ? "is-active" : hover ? "is-hover" : "";
+ return `<${tag} ${attrs}>${label}${tag}> `;
+}
+
+function separatorHtml(char = "/") {
+ return `${char} `;
+}
+
+function collapseHtml() {
+ return `... `;
+}
+
+function listHtml(items, { modifier = "", separator = "/", collapsed = false } = {}) {
+ const modifierClass = VALID_MODIFIERS.includes(modifier) ? `breadcrumb-${modifier}` : "";
+ const parts = [];
+ items.forEach((item, index) => {
+ const isLast = index === items.length - 1;
+ if (collapsed && index > 0 && !isLast && items.length > 4 && index === 1) {
+ parts.push(collapseHtml());
+ parts.push(separatorHtml(separator));
+ return;
+ }
+ if (collapsed && index > 0 && !isLast && items.length > 4) return;
+ parts.push(itemHtml(item.label, { href: item.href, active: isLast, hover: item.hover }));
+ if (!isLast) parts.push(separatorHtml(separator));
+ });
+
+ return `${parts.join("")} `;
+}
+
+function html(options = {}) {
+ const { items = [], modifier = "", separator = "/", collapsed = false } = options;
+ return listHtml(items, { modifier, separator, collapsed });
+}
+
+function create(options = {}) {
+ const { items = [], modifier = "", separator = "/", collapsed = false } = options;
+ const wrapper = document.createElement("div");
+ wrapper.innerHTML = listHtml(items, { modifier, separator, collapsed });
+ return wrapper.firstElementChild;
+}
+
+const Breadcrumbs = { html, create, itemHtml, separatorHtml };
+
+export default Breadcrumbs;
+export { html, create, itemHtml, separatorHtml };
diff --git a/src/js/index.js b/src/js/index.js
index b5bf923..e2f8a2e 100644
--- a/src/js/index.js
+++ b/src/js/index.js
@@ -13,6 +13,9 @@
import Tooltips from "./components/tooltips.js";
import Dropdowns from "./components/dropdowns.js";
import NavigationShell from "./components/navigation-shell.js";
+import Background from "./components/backgrounds.js";
+import Breadcrumbs from "./components/breadcrumbs.js";
+import Accordion from "./components/accordion.js";
const api = {
Icons,
@@ -25,10 +28,21 @@
Tooltips,
Dropdowns,
NavigationShell,
+ Background,
+ Breadcrumbs,
+ Accordion,
Helper: {
iconSvg: Icons.iconSvg,
iconNode: Icons.iconNode,
- iconHtml: Icons.iconHtml
+ iconHtml: Icons.iconHtml,
+ backgroundHtml: Background.html,
+ backgroundCreate: Background.create,
+ breadcrumbHtml: Breadcrumbs.html,
+ breadcrumbCreate: Breadcrumbs.create,
+ accordionInit: Accordion.init,
+ accordionExpand: Accordion.expand,
+ accordionCollapse: Accordion.collapse,
+ accordionToggle: Accordion.toggle
}
};
@@ -40,5 +54,5 @@
});
}
-export { Icons, Toasts, Tabs, Progress, Tables, Lists, Skeleton, Tooltips, Dropdowns, NavigationShell };
+export { Icons, Toasts, Tabs, Progress, Tables, Lists, Skeleton, Tooltips, Dropdowns, NavigationShell, Background, Breadcrumbs, Accordion };
export default api;
diff --git a/src/scss/_palette-colors.scss b/src/scss/_palette-colors.scss
index 6b25130..0adac06 100644
--- a/src/scss/_palette-colors.scss
+++ b/src/scss/_palette-colors.scss
@@ -15,7 +15,7 @@
$neutral-600:#82868D;
$neutral-700:#6B7280;
$neutral-800:#3F4145;
-$neutral-900:#1A1A1A;
+$neutral-900:#1F1F1F;
// lavender
$lavender-50: #F4F3FC;
diff --git a/src/scss/components/_accordion.scss b/src/scss/components/_accordion.scss
new file mode 100644
index 0000000..9cd8939
--- /dev/null
+++ b/src/scss/components/_accordion.scss
@@ -0,0 +1,125 @@
+/* =========================
+ Accordion
+ Figma: not exported as a standalone frame.
+ API kept compatible with gnexus-ui-kit.
+========================= */
+
+@use "../kit-deps" as *;
+
+.accordion {
+ display: grid;
+ width: 100%;
+ max-width: 760px;
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-left-width: $border-width-accent;
+ border-radius: $radius-md;
+ background: $surface-panel-muted;
+ overflow: hidden;
+}
+
+.accordion-item {
+ border-bottom: $border-width-base $border-style-base rgba($neutral-900, 0.08);
+ overflow: hidden;
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ &[open],
+ &.is-open {
+ .accordion-summary {
+ color: $neutral-900;
+ background: rgba($neutral-900, 0.04);
+ }
+
+ .accordion-icon {
+ transform: rotate(180deg);
+ }
+ }
+}
+
+.accordion-summary {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ gap: $space-3;
+ min-height: $control-height-md;
+ padding: $space-3 $space-4;
+ border: 0;
+ color: $neutral-700;
+ background: transparent;
+ cursor: pointer;
+ font-family: $font-family-base;
+ font-size: 14px;
+ font-weight: $font-weight-semibold;
+ line-height: 1.4;
+ text-align: left;
+ transition: color $motion-base $motion-ease, background-color $motion-base $motion-ease;
+
+ &::-webkit-details-marker {
+ display: none;
+ }
+
+ &::marker {
+ content: "";
+ }
+
+ &:focus-visible {
+ @include focus_ring;
+ position: relative;
+ z-index: 1;
+ }
+
+ &:hover,
+ &.is-hover {
+ color: $neutral-900;
+ background-color: rgba($neutral-900, 0.04);
+ }
+}
+
+.accordion-summary-content {
+ display: flex;
+ align-items: center;
+ gap: $space-2;
+ min-width: 0;
+}
+
+.accordion-icon {
+ flex: 0 0 auto;
+ font-size: $icon-size-md;
+ transition: transform $motion-base $motion-ease;
+}
+
+.accordion-panel {
+ overflow: hidden;
+ padding: $space-4;
+ color: $neutral-600;
+ font-family: $font-family-base;
+ font-size: 14px;
+ line-height: 1.5;
+
+ > *:first-child {
+ margin-top: 0;
+ }
+
+ > *:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.disclosure {
+ max-width: 520px;
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-radius: $radius-md;
+ background: $surface-panel-muted;
+
+ .accordion-summary {
+ min-height: $control-height-sm;
+ padding: $space-2 $space-3;
+ }
+
+ .accordion-panel {
+ padding: $space-3;
+ }
+}
diff --git a/src/scss/components/_background.scss b/src/scss/components/_background.scss
new file mode 100644
index 0000000..9f336f1
--- /dev/null
+++ b/src/scss/components/_background.scss
@@ -0,0 +1,55 @@
+/* =========================
+ Background
+ Figma: Backgrounds / Gradient + Shape × Desktop / Tablet / Mobile.
+========================= */
+
+@use "../kit-deps" as *;
+
+.vmk-bg {
+ position: relative;
+ width: 100%;
+ min-height: 100vh;
+ background-color: $neutral-50;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: cover;
+ overflow: hidden;
+
+ // Variant + device combinations
+ &[data-variant="gradient"][data-device="desktop"] {
+ background-image: url("/assets/backgrounds/gradient-desktop.svg");
+ }
+
+ &[data-variant="gradient"][data-device="tablet"] {
+ background-image: url("/assets/backgrounds/gradient-tablet.svg");
+ }
+
+ &[data-variant="gradient"][data-device="mobile"] {
+ background-image: url("/assets/backgrounds/gradient-mobile.svg");
+ }
+
+ &[data-variant="shape"][data-device="desktop"] {
+ background-image: url("/assets/backgrounds/shape-desktop.svg");
+ }
+
+ &[data-variant="shape"][data-device="tablet"] {
+ background-image: url("/assets/backgrounds/shape-tablet.svg");
+ }
+
+ &[data-variant="shape"][data-device="mobile"] {
+ background-image: url("/assets/backgrounds/shape-mobile.svg");
+ }
+
+ // Fixed to viewport
+ &.is-fixed {
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ }
+
+ // Inline preview sizing helper used by the Figma comparison page
+ &.is-preview {
+ min-height: auto;
+ height: 100%;
+ }
+}
diff --git a/src/scss/components/_badges.scss b/src/scss/components/_badges.scss
index dc71801..444f0ca 100644
--- a/src/scss/components/_badges.scss
+++ b/src/scss/components/_badges.scss
@@ -70,6 +70,12 @@
color: $danger-text;
}
+ // Owner / lavender variant from Mode 1 tokens
+ &.badge-owner {
+ background-color: $lavender-300;
+ color: $lavender-700;
+ }
+
// Outline
&.badge-outline {
background-color: transparent;
@@ -77,12 +83,56 @@
color: $neutral-900;
}
- &.badge-primary-outline {
+ &.badge-primary-outline,
+ &.badge-primary.badge-outline {
background-color: transparent;
- border: $border-width-base $border-style-base $neutral-900;
+ border-color: $neutral-900;
color: $neutral-900;
}
+ &.badge-secondary.badge-outline {
+ background-color: transparent;
+ border-color: $mint-600;
+ color: $mint-700;
+ }
+
+ &.badge-accent.badge-outline {
+ background-color: transparent;
+ border-color: $yellow-500;
+ color: $neutral-900;
+ }
+
+ &.badge-success.badge-outline {
+ background-color: transparent;
+ border-color: $success-text;
+ color: $success-text;
+ }
+
+ &.badge-warning.badge-outline {
+ background-color: transparent;
+ border-color: $warning-text;
+ color: $warning-text;
+ }
+
+ &.badge-info.badge-outline {
+ background-color: transparent;
+ border-color: $info-text;
+ color: $info-text;
+ }
+
+ &.badge-danger.badge-outline,
+ &.badge-error.badge-outline {
+ background-color: transparent;
+ border-color: $danger-text;
+ color: $danger-text;
+ }
+
+ &.badge-owner.badge-outline {
+ background-color: transparent;
+ border-color: $lavender-700;
+ color: $lavender-700;
+ }
+
// Sizes
&.badge-sm {
min-height: 20px;
diff --git a/src/scss/components/_breadcrumbs.scss b/src/scss/components/_breadcrumbs.scss
new file mode 100644
index 0000000..52dd46f
--- /dev/null
+++ b/src/scss/components/_breadcrumbs.scss
@@ -0,0 +1,121 @@
+/* =========================
+ Breadcrumbs
+ Figma: Breadcrumbs.
+========================= */
+
+@use "../kit-deps" as *;
+
+.breadcrumb {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: $space-2;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ font-family: $font-family-base;
+ font-size: 14px;
+ font-weight: $font-weight-semibold;
+ line-height: 1;
+ color: $neutral-700;
+
+ .breadcrumb-item {
+ display: inline-flex;
+ align-items: center;
+ gap: $space-2;
+
+ a {
+ color: inherit;
+ text-decoration: none;
+ transition: color $motion-base $motion-ease;
+
+ &:hover,
+ &.is-hover {
+ color: $neutral-900;
+ text-decoration: underline;
+ }
+
+ &:active,
+ &.is-active {
+ color: $neutral-900;
+ }
+ }
+
+ span {
+ color: inherit;
+ }
+
+ &.is-active span {
+ color: $neutral-900;
+ }
+ }
+
+ .breadcrumb-separator {
+ color: $neutral-400;
+ user-select: none;
+ }
+
+ .breadcrumb-collapse {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: $space-1 $space-2;
+ border: none;
+ border-radius: $radius-full;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+ transition: color $motion-base $motion-ease, background-color $motion-base $motion-ease;
+
+ &:hover,
+ &.is-hover {
+ color: $neutral-900;
+ background-color: $neutral-100;
+ }
+ }
+
+ // Inverted / dark-surface variant matching the Breadcrumbs.png frame
+ &.breadcrumb-inverted {
+ color: $neutral-400;
+
+ .breadcrumb-item a {
+ color: $neutral-400;
+
+ &:hover,
+ &.is-hover {
+ color: $neutral-0;
+ }
+
+ &:active,
+ &.is-active {
+ color: $neutral-0;
+ }
+ }
+
+ .breadcrumb-separator {
+ color: $neutral-400;
+ }
+
+ .breadcrumb-item.is-active span {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 36px;
+ padding: $space-1 $space-4;
+ border-radius: $radius-full;
+ background-color: $neutral-0;
+ color: $info-text;
+ }
+
+ .breadcrumb-collapse:hover,
+ .breadcrumb-collapse.is-hover {
+ color: $neutral-0;
+ background-color: rgba($neutral-0, 0.12);
+ }
+ }
+
+ // Sizes
+ &.breadcrumb-sm { font-size: 12px; }
+ &.breadcrumb-lg { font-size: 16px; }
+}
diff --git a/src/scss/components/_buttons.scss b/src/scss/components/_buttons.scss
index 1100fc9..187a4f1 100644
--- a/src/scss/components/_buttons.scss
+++ b/src/scss/components/_buttons.scss
@@ -51,20 +51,21 @@
background-color $motion-base $motion-ease,
border-color $motion-base $motion-ease,
color $motion-base $motion-ease,
- opacity $motion-base $motion-ease,
box-shadow $motion-base $motion-ease;
&:focus-visible {
@include focus_ring;
}
- &:hover:not(:disabled):not(.is-disabled):not(.is-loading) {
+ &:hover:not(:disabled):not(.is-disabled):not(.is-loading),
+ &.is-hover:not(:disabled):not(.is-disabled):not(.is-loading) {
background-color: var(--btn-hover-bg);
border-color: var(--btn-hover-border);
color: var(--btn-hover-text);
}
- &:active:not(:disabled):not(.is-disabled):not(.is-loading) {
+ &:active:not(:disabled):not(.is-disabled):not(.is-loading),
+ &.is-active:not(:disabled):not(.is-disabled):not(.is-loading) {
background-color: var(--btn-focus-bg);
border-color: var(--btn-focus-border);
color: var(--btn-focus-text);
@@ -72,11 +73,11 @@
&:disabled,
&.is-disabled {
- --btn-bg: var(--btn-disabled-bg) !important;
- --btn-border: var(--btn-disabled-border) !important;
- --btn-text: var(--btn-disabled-text) !important;
+ background-color: var(--btn-disabled-bg) !important;
+ border-color: var(--btn-disabled-border) !important;
+ color: var(--btn-disabled-text) !important;
cursor: not-allowed;
- opacity: 0.72;
+ opacity: 1;
}
&.is-loading {
@@ -87,8 +88,6 @@
}
}
- // Make the spinner inherit the current token color even when a variant
- // overrides it mid-flight.
.vmk-icon {
color: currentColor;
flex-shrink: 0;
@@ -145,22 +144,23 @@
&.btn-xxs { width: 24px; }
}
-// Variants
-// Primary — filled dark (Figma action.primary-bg)
+// Variants aligned with Figma Button.png
+
+// Primary — filled yellow CTA
.btn-primary {
- --btn-bg: #{$neutral-900};
- --btn-border: #{$neutral-900};
- --btn-text: #{$neutral-25};
- --btn-hover-bg: #{$neutral-800};
- --btn-hover-border: #{$neutral-800};
- --btn-focus-bg: #{$neutral-800};
- --btn-focus-border: #{$neutral-800};
- --btn-disabled-bg: #{$neutral-400};
- --btn-disabled-border: #{$neutral-400};
- --btn-disabled-text: #{$neutral-100};
+ --btn-bg: #{$yellow-300};
+ --btn-border: #{$yellow-300};
+ --btn-text: #{$neutral-900};
+ --btn-hover-bg: #{$yellow-400};
+ --btn-hover-border: #{$yellow-400};
+ --btn-focus-bg: #{$yellow-500};
+ --btn-focus-border: #{$yellow-500};
+ --btn-disabled-bg: #{$neutral-200};
+ --btn-disabled-border: #{$neutral-200};
+ --btn-disabled-text: #{$neutral-500};
}
-// Accent — CTA yellow (Figma action.cta-bg)
+// Accent — yellow alias, kept for API compatibility.
.btn-accent {
--btn-bg: #{$yellow-300};
--btn-border: #{$yellow-300};
@@ -169,53 +169,51 @@
--btn-hover-border: #{$yellow-400};
--btn-focus-bg: #{$yellow-500};
--btn-focus-border: #{$yellow-500};
- --btn-disabled-bg: #{$yellow-100};
- --btn-disabled-border: #{$yellow-100};
+ --btn-disabled-bg: #{$neutral-200};
+ --btn-disabled-border: #{$neutral-200};
--btn-disabled-text: #{$neutral-500};
}
-// Secondary — mint fill (Figma color.bg.secondary)
+// Secondary — mint outline (Figma secondary row)
.btn-secondary {
- --btn-bg: #{$mint-300};
- --btn-border: #{$mint-300};
- --btn-text: #{$neutral-900};
- --btn-hover-bg: #{$mint-400};
- --btn-hover-border: #{$mint-400};
- --btn-focus-bg: #{$mint-500};
- --btn-focus-border: #{$mint-500};
- --btn-disabled-bg: #{$mint-100};
- --btn-disabled-border: #{$mint-100};
- --btn-disabled-text: #{$neutral-500};
+ --btn-bg: transparent;
+ --btn-border: #{$mint-600};
+ --btn-text: #{$mint-800};
+ --btn-hover-bg: #{$mint-100};
+ --btn-hover-border: #{$mint-600};
+ --btn-focus-bg: #{$mint-200};
+ --btn-focus-border: #{$mint-600};
+ --btn-disabled-bg: transparent;
+ --btn-disabled-border: #{$mint-200};
+ --btn-disabled-text: #{$mint-500};
}
-// Tertiary — transparent dark text
+// Tertiary — ghost dark text (Figma tertiary row)
.btn-tertiary {
--btn-bg: transparent;
--btn-border: transparent;
--btn-text: #{$neutral-900};
- --btn-hover-bg: #{rgba($neutral-100, 0.64)};
+ --btn-hover-bg: #{$neutral-100};
--btn-hover-border: transparent;
- --btn-focus-bg: #{$neutral-100};
+ --btn-focus-bg: #{$neutral-200};
--btn-focus-border: transparent;
--btn-disabled-bg: transparent;
--btn-disabled-border: transparent;
- --btn-disabled-text: #{$neutral-500};
+ --btn-disabled-text: #{$neutral-400};
- // Figma: tertiary icon-only buttons use a smaller radius than the pill shape.
&.btn-icon-only {
border-radius: $radius-md;
}
}
-// Semantic variants — use the primitive alert colors so existing projects
-// using success / warning / info keep working.
+// Semantic variants
.btn-success {
--btn-bg: #{$success-bg};
--btn-border: #{$success-text};
--btn-text: #{$success-text};
- --btn-hover-bg: #{rgba($success-bg, 0.8)};
+ --btn-hover-bg: #{color.mix($success-text, $success-bg, 12%)};
--btn-hover-border: #{$success-text};
- --btn-focus-bg: #{rgba($success-bg, 0.64)};
+ --btn-focus-bg: #{color.mix($success-text, $success-bg, 24%)};
--btn-disabled-bg: #{$success-bg};
--btn-disabled-border: #{rgba($success-text, 0.4)};
--btn-disabled-text: #{rgba($success-text, 0.5)};
@@ -225,9 +223,9 @@
--btn-bg: #{$warning-bg};
--btn-border: #{$warning-text};
--btn-text: #{$warning-text};
- --btn-hover-bg: #{rgba($warning-bg, 0.8)};
+ --btn-hover-bg: #{color.mix($warning-text, $warning-bg, 12%)};
--btn-hover-border: #{$warning-text};
- --btn-focus-bg: #{rgba($warning-bg, 0.64)};
+ --btn-focus-bg: #{color.mix($warning-text, $warning-bg, 24%)};
--btn-disabled-bg: #{$warning-bg};
--btn-disabled-border: #{rgba($warning-text, 0.4)};
--btn-disabled-text: #{rgba($warning-text, 0.5)};
@@ -237,15 +235,15 @@
--btn-bg: #{$info-bg};
--btn-border: #{$info-text};
--btn-text: #{$info-text};
- --btn-hover-bg: #{rgba($info-bg, 0.8)};
+ --btn-hover-bg: #{color.mix($info-text, $info-bg, 12%)};
--btn-hover-border: #{$info-text};
- --btn-focus-bg: #{rgba($info-bg, 0.64)};
+ --btn-focus-bg: #{color.mix($info-text, $info-bg, 24%)};
--btn-disabled-bg: #{$info-bg};
--btn-disabled-border: #{rgba($info-text, 0.4)};
--btn-disabled-text: #{rgba($info-text, 0.5)};
}
-// Danger / Error — destructive red fill (Figma destructive primary)
+// Danger / Error — destructive red fill
.btn-danger,
.btn-error {
--btn-bg: #{$danger-text};
@@ -255,12 +253,12 @@
--btn-hover-border: #{color.adjust($danger-text, $lightness: -8%)};
--btn-focus-bg: #{color.adjust($danger-text, $lightness: -12%)};
--btn-focus-border: #{color.adjust($danger-text, $lightness: -12%)};
- --btn-disabled-bg: #{rgba($danger-text, 0.4)};
- --btn-disabled-border: #{rgba($danger-text, 0.4)};
- --btn-disabled-text: #{$neutral-0};
+ --btn-disabled-bg: #{$danger-bg};
+ --btn-disabled-border: #{$danger-bg};
+ --btn-disabled-text: #{rgba($danger-text, 0.5)};
}
-// Destructive secondary and tertiary variants for richer Figma coverage.
+// Destructive secondary — light red outline
.btn-danger-secondary,
.btn-error-secondary {
--btn-bg: #{$danger-bg};
@@ -269,11 +267,13 @@
--btn-hover-bg: #{color.adjust($danger-bg, $lightness: -4%)};
--btn-hover-border: #{$danger-text};
--btn-focus-bg: #{color.adjust($danger-bg, $lightness: -8%)};
+ --btn-focus-border: #{$danger-text};
--btn-disabled-bg: #{$danger-bg};
- --btn-disabled-border: #{rgba($danger-text, 0.4)};
+ --btn-disabled-border: #{rgba($danger-text, 0.3)};
--btn-disabled-text: #{rgba($danger-text, 0.5)};
}
+// Destructive tertiary — red ghost
.btn-danger-tertiary,
.btn-error-tertiary {
--btn-bg: transparent;
@@ -282,6 +282,7 @@
--btn-hover-bg: #{$danger-bg};
--btn-hover-border: transparent;
--btn-focus-bg: #{color.adjust($danger-bg, $lightness: -4%)};
+ --btn-focus-border: transparent;
--btn-disabled-bg: transparent;
--btn-disabled-border: transparent;
--btn-disabled-text: #{rgba($danger-text, 0.5)};
diff --git a/src/scss/components/_divider.scss b/src/scss/components/_divider.scss
new file mode 100644
index 0000000..0b5e6ad
--- /dev/null
+++ b/src/scss/components/_divider.scss
@@ -0,0 +1,32 @@
+/* =========================
+ Divider
+ Figma: Divider Container.
+========================= */
+
+@use "../kit-deps" as *;
+
+.divider {
+ display: block;
+ width: 100%;
+ height: 1px;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background-color: $neutral-100;
+
+ &[data-variant="strong"] {
+ background-color: $neutral-300;
+ }
+
+ &[data-variant="dark"] {
+ background-color: $neutral-800;
+ }
+
+ &[data-orientation="vertical"] {
+ display: inline-block;
+ width: 1px;
+ height: 100%;
+ min-height: $space-6;
+ vertical-align: middle;
+ }
+}
diff --git a/src/scss/components/_file-upload.scss b/src/scss/components/_file-upload.scss
new file mode 100644
index 0000000..56248d8
--- /dev/null
+++ b/src/scss/components/_file-upload.scss
@@ -0,0 +1,298 @@
+/* =========================
+ File Upload & File Attachment
+ Figma: Attached File.
+ API kept compatible with gnexus-ui-kit.
+========================= */
+
+@use "../kit-deps" as *;
+
+$file-attachment-size: 240px;
+
+.file-upload-panel {
+ width: 100%;
+ max-width: 680px;
+}
+
+.file-upload-form {
+ display: flex;
+ flex-direction: column;
+ gap: $space-4;
+}
+
+.file-upload-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: $space-3;
+}
+
+.file-upload-heading {
+ display: flex;
+ flex-direction: column;
+ gap: $space-1;
+}
+
+.file-upload-title {
+ margin: 0;
+ color: $neutral-900;
+ font-family: $font-family-base;
+ font-size: 18px;
+ font-weight: $font-weight-bold;
+ line-height: 1.3;
+}
+
+.file-upload-description {
+ margin: 0;
+ color: $neutral-600;
+ font-family: $font-family-base;
+ font-size: 14px;
+ line-height: 1.4;
+}
+
+.file-upload-dropzone {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: $space-3;
+ width: 100%;
+ padding: $space-8 $space-6;
+ border: 2px dashed $border-color-muted;
+ border-radius: $radius-lg;
+ background: $surface-panel-muted;
+ color: $neutral-700;
+ text-align: center;
+ cursor: pointer;
+ transition: border-color $motion-base $motion-ease, background-color $motion-base $motion-ease;
+
+ &:hover,
+ &.is-hover {
+ border-color: $mint-600;
+ background: rgba($mint-300, 0.24);
+ }
+
+ input[type="file"] {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+}
+
+.file-upload-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ border-radius: $radius-full;
+ background: $neutral-0;
+ color: $mint-700;
+ font-size: 24px;
+}
+
+.file-upload-body {
+ display: flex;
+ flex-direction: column;
+ gap: $space-1;
+}
+
+.file-upload-primary {
+ color: $neutral-900;
+ font-family: $font-family-base;
+ font-size: 15px;
+ font-weight: $font-weight-semibold;
+}
+
+.file-upload-secondary {
+ color: $neutral-600;
+ font-family: $font-family-base;
+ font-size: 13px;
+}
+
+.file-upload-preview {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax($file-attachment-size, 1fr));
+ gap: $space-4;
+}
+
+.file-upload-preview-item,
+.file-attachment {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ width: 100%;
+ max-width: $file-attachment-size;
+ aspect-ratio: 1 / 1;
+ padding: $space-4;
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-radius: $radius-xl;
+ background: $neutral-0;
+ color: $neutral-900;
+ box-sizing: border-box;
+ overflow: hidden;
+}
+
+.file-upload-preview-remove,
+.file-attachment-remove {
+ position: absolute;
+ top: $space-3;
+ right: $space-3;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 0;
+ border-radius: $radius-full;
+ background: transparent;
+ color: $neutral-700;
+ font-size: $icon-size-md;
+ cursor: pointer;
+ transition: color $motion-base $motion-ease, background-color $motion-base $motion-ease;
+
+ &:hover,
+ &.is-hover {
+ color: $neutral-900;
+ background: $neutral-100;
+ }
+
+ &:focus-visible {
+ @include focus_ring;
+ }
+}
+
+.file-upload-preview-visual,
+.file-attachment-visual {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+}
+
+.file-upload-preview-type,
+.file-attachment-type {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 72px;
+ height: 72px;
+ border-radius: $radius-lg;
+ background: $neutral-100;
+ color: $neutral-600;
+ font-family: $font-family-base;
+ font-size: 14px;
+ font-weight: $font-weight-bold;
+ text-transform: uppercase;
+}
+
+.file-upload-preview-name,
+.file-attachment-name {
+ position: relative;
+ z-index: 1;
+ display: block;
+ margin-top: auto;
+ color: $neutral-900;
+ font-family: $font-family-base;
+ font-size: 16px;
+ font-weight: $font-weight-semibold;
+ line-height: 1.3;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.file-upload-preview-meta,
+.file-attachment-meta {
+ position: relative;
+ z-index: 1;
+ display: block;
+ margin-top: $space-1;
+ color: $neutral-600;
+ font-family: $font-family-base;
+ font-size: 12px;
+ line-height: 1.3;
+}
+
+.file-attachment-header {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: $space-2;
+}
+
+.file-attachment-format {
+ display: inline-flex;
+ align-items: center;
+ padding: $space-1 $space-2;
+ border-radius: $radius-sm;
+ background: $neutral-100;
+ color: $neutral-700;
+ font-family: $font-family-base;
+ font-size: 12px;
+ font-weight: $font-weight-semibold;
+ text-transform: uppercase;
+}
+
+// State variants
+.file-attachment.is-placeholder,
+.file-upload-preview-item.is-placeholder {
+ .file-attachment-visual,
+ .file-upload-preview-visual {
+ background: repeating-conic-gradient($neutral-200 0% 25%, $neutral-25 0% 50%) 50% / 50px 50px;
+ }
+}
+
+.file-attachment.is-error,
+.file-upload-preview-item.is-error,
+.file-attachment.is-retry,
+.file-upload-preview-item.is-retry {
+ background: $neutral-100;
+
+ .file-attachment-visual,
+ .file-upload-preview-visual {
+ color: $danger-text;
+ font-size: 48px;
+
+ &::before {
+ content: "";
+ position: absolute;
+ width: 96px;
+ height: 96px;
+ border-radius: $radius-full;
+ background: $neutral-0;
+ z-index: -1;
+ }
+ }
+}
+
+.file-attachment.is-empty,
+.file-upload-preview-item.is-empty {
+ .file-attachment-visual,
+ .file-upload-preview-visual {
+ display: none;
+ }
+}
+
+.file-upload-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: $space-2;
+}
diff --git a/src/scss/components/_forms.scss b/src/scss/components/_forms.scss
index b6c15cf..8691c7f 100644
--- a/src/scss/components/_forms.scss
+++ b/src/scss/components/_forms.scss
@@ -55,7 +55,8 @@
box-shadow $motion-base $motion-ease,
background-color $motion-base $motion-ease;
- &:hover:not(:disabled):not([readonly]) {
+ &:hover:not(:disabled):not([readonly]),
+ &.is-hover:not(:disabled):not([readonly]) {
border-color: $mint-600;
}
diff --git a/src/scss/components/_stepper.scss b/src/scss/components/_stepper.scss
new file mode 100644
index 0000000..ebf5fb7
--- /dev/null
+++ b/src/scss/components/_stepper.scss
@@ -0,0 +1,134 @@
+/* =========================
+ Stepper
+ Figma: Number Progress Indicator.
+ API kept compatible with gnexus-ui-kit.
+========================= */
+
+@use "../kit-deps" as *;
+
+.steps {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: $space-3;
+ width: 100%;
+ max-width: 900px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+
+ .step {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: $space-2;
+ min-height: 120px;
+ padding: $space-4;
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-left-width: $border-width-accent;
+ border-radius: $radius-md;
+ background: $surface-panel-muted;
+ }
+
+ .step-marker {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: $control-height-sm;
+ height: $control-height-sm;
+ color: $neutral-700;
+ background: $neutral-0;
+ border: $border-width-base $border-style-base $border-color-muted;
+ border-radius: $radius-full;
+ font-family: $font-family-base;
+ font-size: 14px;
+ font-weight: $font-weight-bold;
+ line-height: 1;
+ }
+
+ .step-title {
+ margin: 0;
+ color: $neutral-900;
+ font-family: $font-family-base;
+ font-size: 16px;
+ font-weight: $font-weight-bold;
+ line-height: 1.3;
+ }
+
+ .step-text {
+ margin: 0;
+ color: $neutral-600;
+ font-family: $font-family-base;
+ font-size: 14px;
+ line-height: 1.4;
+ }
+
+ .step-complete {
+ border-color: $success-text;
+
+ .step-marker {
+ color: $neutral-0;
+ background: $success-text;
+ border-color: $success-text;
+ }
+
+ .step-title {
+ color: $success-text;
+ }
+ }
+
+ .step-current {
+ border-color: $yellow-500;
+
+ .step-marker {
+ color: $neutral-900;
+ background: $yellow-300;
+ border-color: $yellow-500;
+ }
+
+ .step-title {
+ color: $neutral-900;
+ }
+ }
+
+ .step-disabled {
+ opacity: 0.5;
+
+ .step-marker {
+ color: $neutral-400;
+ background: $neutral-100;
+ }
+ }
+
+ &.steps-vertical {
+ grid-template-columns: 1fr;
+ max-width: 520px;
+ gap: 0;
+
+ .step {
+ min-height: auto;
+ border-bottom-width: 0;
+ border-radius: 0;
+
+ &:first-child {
+ border-radius: $radius-md $radius-md 0 0;
+ }
+
+ &:last-child {
+ border-bottom-width: $border-width-base;
+ border-radius: 0 0 $radius-md $radius-md;
+ }
+ }
+ }
+}
+
+@media (max-width: 992px) {
+ .steps {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 576px) {
+ .steps {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/scss/kit.scss b/src/scss/kit.scss
index 9b1a703..a8495ab 100644
--- a/src/scss/kit.scss
+++ b/src/scss/kit.scss
@@ -2,6 +2,7 @@
@use "kit-deps" as *;
@use "motion";
@use "components/icons";
+@use "components/background";
@use "components/buttons";
@use "components/badges";
@use "components/alerts";
@@ -19,6 +20,11 @@
@use "components/chips";
@use "components/progress";
@use "components/tables";
+@use "components/breadcrumbs";
+@use "components/divider";
+@use "components/accordion";
+@use "components/stepper";
+@use "components/file-upload";
@use "components/lists";
@use "components/skeleton";
@use "components/tooltip";
diff --git a/src/vue/components/GnAccordion.js b/src/vue/components/GnAccordion.js
new file mode 100644
index 0000000..29e143b
--- /dev/null
+++ b/src/vue/components/GnAccordion.js
@@ -0,0 +1,56 @@
+/**
+ * GnAccordion - Collapsible content sections.
+ * Keeps API compatible with gnexus-ui-kit.
+ */
+
+import { defineComponent, h, ref } from "vue";
+import { cx, iconNode } from "../utils.js";
+
+export default defineComponent({
+ name: "GnAccordion",
+ props: {
+ items: { type: Array, required: true },
+ modelValue: { type: [String, Array], default: "" },
+ multiple: { type: Boolean, default: false }
+ },
+ emits: ["update:modelValue"],
+ setup(props, { emit, slots }) {
+ const localOpen = ref(props.multiple ? [] : "");
+
+ const getOpen = () => props.modelValue || localOpen.value;
+ const isOpen = id => props.multiple ? getOpen().includes(id) : getOpen() === id;
+ const toggle = id => {
+ let next;
+
+ if(props.multiple) {
+ const current = [...getOpen()];
+ next = current.includes(id) ? current.filter(item => item !== id) : [...current, id];
+ } else {
+ next = isOpen(id) ? "" : id;
+ }
+
+ localOpen.value = next;
+ emit("update:modelValue", next);
+ };
+
+ return () => h("div", { class: "accordion" }, props.items.map(item => {
+ const open = isOpen(item.id);
+
+ return h("section", { class: cx("accordion-item", { "is-open": open }) }, [
+ h("button", {
+ class: "accordion-summary",
+ type: "button",
+ "aria-expanded": open ? "true" : "false",
+ onClick: () => toggle(item.id)
+ }, [
+ h("span", { class: "accordion-summary-content" }, [
+ iconNode(item.icon),
+ item.label
+ ]),
+ h("i", { class: cx("ph ph-caret-down accordion-icon", { "is-open": open }), "aria-hidden": "true" })
+ ]),
+ open && h("div", { class: "accordion-panel" }, slots[item.id]?.({ item, open }) || item.content)
+ ]);
+ }));
+ }
+});
diff --git a/src/vue/components/GnBackground.js b/src/vue/components/GnBackground.js
new file mode 100644
index 0000000..2429575
--- /dev/null
+++ b/src/vue/components/GnBackground.js
@@ -0,0 +1,37 @@
+/**
+ * GnBackground - Page background wrapper (gradient / shape × desktop / tablet / mobile).
+ */
+
+import { defineComponent, h, computed } from "vue";
+
+export default defineComponent({
+ name: "GnBackground",
+ props: {
+ variant: { type: String, default: "gradient" },
+ device: { type: String, default: "desktop" },
+ fixed: { type: Boolean, default: false },
+ preview: { type: Boolean, default: false }
+ },
+ setup(props, { slots }) {
+ const safeVariant = computed(() =>
+ ["gradient", "shape"].includes(props.variant) ? props.variant : "gradient"
+ );
+ const safeDevice = computed(() =>
+ ["desktop", "tablet", "mobile"].includes(props.device) ? props.device : "desktop"
+ );
+
+ const classes = computed(() => {
+ const list = ["vmk-bg"];
+ if (props.fixed) list.push("is-fixed");
+ if (props.preview) list.push("is-preview");
+ return list;
+ });
+
+ return () =>
+ h("div", {
+ class: classes.value,
+ "data-variant": safeVariant.value,
+ "data-device": safeDevice.value
+ }, slots.default?.());
+ }
+});
diff --git a/src/vue/components/GnBreadcrumbItem.js b/src/vue/components/GnBreadcrumbItem.js
new file mode 100644
index 0000000..33cacac
--- /dev/null
+++ b/src/vue/components/GnBreadcrumbItem.js
@@ -0,0 +1,32 @@
+/**
+ * GnBreadcrumbItem - Single breadcrumb segment.
+ */
+
+import { defineComponent, h, computed } from "vue";
+import { cx } from "../utils.js";
+
+export default defineComponent({
+ name: "GnBreadcrumbItem",
+ props: {
+ href: { type: String, default: "" },
+ active: { type: Boolean, default: false },
+ separator: { type: String, default: "/" }
+ },
+ setup(props, { slots }) {
+ const classes = computed(() => cx("breadcrumb-item", { "is-active": props.active }));
+
+ return () => {
+ const children = [];
+ const content = props.href
+ ? h("a", { href: props.href }, slots.default?.())
+ : h("span", null, slots.default?.());
+ children.push(content);
+ if (!props.active) {
+ children.push(
+ h("span", { class: "breadcrumb-separator", "aria-hidden": "true" }, props.separator)
+ );
+ }
+ return h("li", { class: classes.value }, children);
+ };
+ }
+});
diff --git a/src/vue/components/GnBreadcrumbs.js b/src/vue/components/GnBreadcrumbs.js
new file mode 100644
index 0000000..fd9e0d1
--- /dev/null
+++ b/src/vue/components/GnBreadcrumbs.js
@@ -0,0 +1,29 @@
+/**
+ * GnBreadcrumbs - Navigation breadcrumb list.
+ */
+
+import { defineComponent, h, computed } from "vue";
+import { cx } from "../utils.js";
+
+export default defineComponent({
+ name: "GnBreadcrumbs",
+ props: {
+ inverted: { type: Boolean, default: false },
+ size: { type: String, default: "" },
+ separator: { type: String, default: "/" }
+ },
+ setup(props, { slots }) {
+ const classes = computed(() =>
+ cx("breadcrumb", {
+ "breadcrumb-inverted": props.inverted,
+ "breadcrumb-sm": props.size === "sm",
+ "breadcrumb-lg": props.size === "lg"
+ })
+ );
+
+ return () =>
+ h("nav", { "aria-label": "Breadcrumb" }, [
+ h("ol", { class: classes.value }, slots.default?.())
+ ]);
+ }
+});
diff --git a/src/vue/components/GnFileAttachment.js b/src/vue/components/GnFileAttachment.js
new file mode 100644
index 0000000..c79ec6c
--- /dev/null
+++ b/src/vue/components/GnFileAttachment.js
@@ -0,0 +1,57 @@
+/**
+ * GnFileAttachment - Static file attachment card.
+ * Matches the Figma "Attached File" frame.
+ */
+
+import { defineComponent, h } from "vue";
+import { cx, iconNode } from "../utils.js";
+
+export default defineComponent({
+ name: "GnFileAttachment",
+ props: {
+ name: { type: String, required: true },
+ format: { type: String, default: "" },
+ size: { type: String, default: "" },
+ state: { type: String, default: "default" },
+ previewUrl: { type: String, default: "" },
+ removable: { type: Boolean, default: true }
+ },
+ emits: ["remove"],
+ setup(props, { emit }) {
+ const formatLabel = props.format ? props.format.slice(0, 6).toUpperCase() : "";
+ const stateClass = ["placeholder", "error", "retry"].includes(props.state) ? `is-${props.state}` : "";
+
+ const renderVisual = () => {
+ if(props.previewUrl) {
+ return h("img", { src: props.previewUrl, alt: "" });
+ }
+
+ if(props.state === "error") {
+ return iconNode("ph-warning");
+ }
+
+ if(props.state === "retry") {
+ return iconNode("ph-arrows-clockwise");
+ }
+
+ return h("span", { class: "file-attachment-type" }, formatLabel || "FILE");
+ };
+
+ return () => h("figure", { class: cx("file-attachment", stateClass) }, [
+ h("div", { class: "file-attachment-header" }, [
+ formatLabel && h("span", { class: "file-attachment-format" }, formatLabel),
+ props.removable && h("button", {
+ class: "file-attachment-remove",
+ type: "button",
+ "aria-label": `Remove ${props.name}`,
+ onClick: () => emit("remove")
+ }, [iconNode("ph-x")])
+ ]),
+ h("div", { class: "file-attachment-visual" }, [renderVisual()]),
+ h("figcaption", {}, [
+ h("span", { class: "file-attachment-name" }, props.name),
+ props.size && h("span", { class: "file-attachment-meta" }, props.size)
+ ])
+ ]);
+ }
+});
diff --git a/src/vue/components/GnFileUpload.js b/src/vue/components/GnFileUpload.js
new file mode 100644
index 0000000..61fa697
--- /dev/null
+++ b/src/vue/components/GnFileUpload.js
@@ -0,0 +1,142 @@
+/**
+ * GnFileUpload - File dropzone and attachment list.
+ * Keeps API compatible with gnexus-ui-kit.
+ */
+
+import { defineComponent, h, onBeforeUnmount, ref, watch } from "vue";
+import { iconNode } from "../utils.js";
+import GnButton from "./GnButton.js";
+import GnBadge from "./GnBadge.js";
+
+function fileType(file) {
+ const ext = file.name.split(".").pop();
+ return ext ? ext.slice(0, 6).toUpperCase() : "FILE";
+}
+
+function fileSize(file) {
+ if(!file.size) {
+ return "0 B";
+ }
+
+ const units = ["B", "KB", "MB", "GB"];
+ const index = Math.min(Math.floor(Math.log(file.size) / Math.log(1024)), units.length - 1);
+ const value = file.size / Math.pow(1024, index);
+ return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
+}
+
+export default defineComponent({
+ name: "GnFileUpload",
+ props: {
+ modelValue: { type: Array, default: () => [] },
+ title: { type: String, default: "Upload files" },
+ description: { type: String, default: "Attach documents, archives or images." },
+ primary: { type: String, default: "Choose files" },
+ secondary: { type: String, default: "Images get thumbnails, other files show their type" },
+ badge: { type: String, default: "" },
+ multiple: { type: Boolean, default: true },
+ accept: { type: String, default: "" }
+ },
+ emits: ["update:modelValue", "change"],
+ setup(props, { emit, slots }) {
+ const urls = ref(new Map());
+ const revokeFile = file => {
+ const url = urls.value.get(file);
+
+ if(url) {
+ URL.revokeObjectURL(url);
+ urls.value.delete(file);
+ }
+ };
+ const revokeAll = () => {
+ urls.value.forEach(url => URL.revokeObjectURL(url));
+ urls.value.clear();
+ };
+ const setFiles = fileList => {
+ const files = Array.from(fileList || []);
+ emit("update:modelValue", files);
+ emit("change", files);
+ };
+ const remove = index => {
+ revokeFile(props.modelValue[index]);
+ const files = props.modelValue.filter((_, itemIndex) => itemIndex !== index);
+ emit("update:modelValue", files);
+ emit("change", files);
+ };
+ const previewUrl = file => {
+ if(!file.type?.startsWith("image/")) {
+ return "";
+ }
+
+ if(!urls.value.has(file)) {
+ urls.value.set(file, URL.createObjectURL(file));
+ }
+
+ return urls.value.get(file);
+ };
+
+ watch(() => props.modelValue, files => {
+ const active = new Set(files);
+ [...urls.value.keys()].forEach(file => {
+ if(!active.has(file)) {
+ revokeFile(file);
+ }
+ });
+ });
+
+ onBeforeUnmount(revokeAll);
+
+ return () => h("div", { class: "file-upload-panel" }, [
+ h("div", { class: "file-upload-form" }, [
+ h("div", { class: "file-upload-header" }, [
+ h("div", { class: "file-upload-heading" }, [
+ h("h3", { class: "file-upload-title" }, slots.title?.() || props.title),
+ h("p", { class: "file-upload-description" }, slots.description?.() || props.description)
+ ]),
+ props.badge && h(GnBadge, { variant: "info" }, () => props.badge)
+ ]),
+ h("label", { class: "file-upload-dropzone" }, [
+ h("span", { class: "file-upload-icon", "aria-hidden": "true" }, [iconNode("ph-cloud-arrow-up")]),
+ h("span", { class: "file-upload-body" }, [
+ h("span", { class: "file-upload-primary" }, props.primary),
+ h("span", { class: "file-upload-secondary" }, props.secondary)
+ ]),
+ h("input", {
+ type: "file",
+ multiple: props.multiple,
+ accept: props.accept || undefined,
+ onChange: event => setFiles(event.target.files)
+ })
+ ]),
+ h("div", { class: "file-upload-preview", hidden: !props.modelValue.length }, props.modelValue.map((file, index) => h("figure", {
+ class: "file-upload-preview-item"
+ }, [
+ h("button", {
+ class: "file-upload-preview-remove",
+ type: "button",
+ "aria-label": `Remove ${file.name}`,
+ onClick: () => remove(index)
+ }, [iconNode("ph-x")]),
+ h("div", { class: "file-upload-preview-visual" }, previewUrl(file)
+ ? h("img", { src: previewUrl(file), alt: "" })
+ : h("span", { class: "file-upload-preview-type" }, fileType(file))
+ ),
+ h("figcaption", {}, [
+ h("span", { class: "file-upload-preview-name" }, file.name),
+ h("span", { class: "file-upload-preview-meta" }, `${fileType(file)} / ${fileSize(file)}`)
+ ])
+ ]))),
+ slots.actions && h("div", { class: "file-upload-actions" }, slots.actions()),
+ !slots.actions && props.modelValue.length > 0 && h("div", { class: "file-upload-actions" }, [
+ h(GnButton, {
+ variant: "secondary",
+ size: "sm",
+ onClick: () => {
+ revokeAll();
+ setFiles([]);
+ }
+ }, () => "Reset")
+ ])
+ ])
+ ]);
+ }
+});
diff --git a/src/vue/components/GnSteps.js b/src/vue/components/GnSteps.js
new file mode 100644
index 0000000..0d2403d
--- /dev/null
+++ b/src/vue/components/GnSteps.js
@@ -0,0 +1,32 @@
+/**
+ * GnSteps - Multi-step progress indicator.
+ * Keeps API compatible with gnexus-ui-kit.
+ */
+
+import { defineComponent, h } from "vue";
+import { cx } from "../utils.js";
+
+export default defineComponent({
+ name: "GnSteps",
+ inheritAttrs: false,
+ props: {
+ items: { type: Array, required: true },
+ vertical: { type: Boolean, default: false }
+ },
+ setup(props, { attrs }) {
+ return () => h("ol", {
+ ...attrs,
+ class: cx("steps", { "steps-vertical": props.vertical }, attrs.class)
+ }, props.items.map((item, index) => h("li", {
+ class: cx("step", {
+ "step-complete": item.status === "complete",
+ "step-current": item.status === "current",
+ "step-disabled": item.disabled || item.status === "disabled"
+ })
+ }, [
+ h("span", { class: "step-marker" }, item.marker || String(index + 1)),
+ h("h3", { class: "step-title" }, item.title),
+ item.text && h("p", { class: "step-text" }, item.text)
+ ])));
+ }
+});
diff --git a/src/vue/index.js b/src/vue/index.js
index f406583..af15014 100644
--- a/src/vue/index.js
+++ b/src/vue/index.js
@@ -35,5 +35,12 @@
export { default as GnDropdownItem } from "./components/GnDropdownItem.js";
export { default as GnNavigationShell } from "./components/GnNavigationShell.js";
export { default as GnNavigationItem } from "./components/GnNavigationItem.js";
+export { default as GnBackground } from "./components/GnBackground.js";
+export { default as GnBreadcrumbs } from "./components/GnBreadcrumbs.js";
+export { default as GnBreadcrumbItem } from "./components/GnBreadcrumbItem.js";
+export { default as GnAccordion } from "./components/GnAccordion.js";
+export { default as GnSteps } from "./components/GnSteps.js";
+export { default as GnFileUpload } from "./components/GnFileUpload.js";
+export { default as GnFileAttachment } from "./components/GnFileAttachment.js";
export { useToast } from "./composables/useToast.js";
export { components, default as VMKUiVue } from "./plugin.js";
diff --git a/src/vue/plugin.js b/src/vue/plugin.js
index b4cecf5..66c0372 100644
--- a/src/vue/plugin.js
+++ b/src/vue/plugin.js
@@ -35,6 +35,13 @@
import GnDropdownItem from "./components/GnDropdownItem.js";
import GnNavigationShell from "./components/GnNavigationShell.js";
import GnNavigationItem from "./components/GnNavigationItem.js";
+import GnBackground from "./components/GnBackground.js";
+import GnBreadcrumbs from "./components/GnBreadcrumbs.js";
+import GnBreadcrumbItem from "./components/GnBreadcrumbItem.js";
+import GnAccordion from "./components/GnAccordion.js";
+import GnSteps from "./components/GnSteps.js";
+import GnFileUpload from "./components/GnFileUpload.js";
+import GnFileAttachment from "./components/GnFileAttachment.js";
export const components = {
GnButton,
@@ -68,7 +75,14 @@
GnDropdown,
GnDropdownItem,
GnNavigationShell,
- GnNavigationItem
+ GnNavigationItem,
+ GnBackground,
+ GnBreadcrumbs,
+ GnBreadcrumbItem,
+ GnAccordion,
+ GnSteps,
+ GnFileUpload,
+ GnFileAttachment
};
export default {
diff --git a/tests/figma-baselines/alerts-baseline.png b/tests/figma-baselines/alerts-baseline.png
new file mode 100644
index 0000000..a4a8795
--- /dev/null
+++ b/tests/figma-baselines/alerts-baseline.png
Binary files differ
diff --git a/tests/figma-baselines/attached-file-baseline.png b/tests/figma-baselines/attached-file-baseline.png
new file mode 100644
index 0000000..c0a5875
--- /dev/null
+++ b/tests/figma-baselines/attached-file-baseline.png
Binary files differ
diff --git a/tests/figma-baselines/badges-baseline.png b/tests/figma-baselines/badges-baseline.png
new file mode 100644
index 0000000..bbc4bb9
--- /dev/null
+++ b/tests/figma-baselines/badges-baseline.png
Binary files differ
diff --git a/tests/figma-baselines/breadcrumb-row-235.png b/tests/figma-baselines/breadcrumb-row-235.png
new file mode 100644
index 0000000..9da0a07
--- /dev/null
+++ b/tests/figma-baselines/breadcrumb-row-235.png
Binary files differ
diff --git a/tests/figma-baselines/breadcrumb-row-411.png b/tests/figma-baselines/breadcrumb-row-411.png
new file mode 100644
index 0000000..1e8827f
--- /dev/null
+++ b/tests/figma-baselines/breadcrumb-row-411.png
Binary files differ
diff --git a/tests/figma-baselines/breadcrumb-row-545.png b/tests/figma-baselines/breadcrumb-row-545.png
new file mode 100644
index 0000000..8c3a311
--- /dev/null
+++ b/tests/figma-baselines/breadcrumb-row-545.png
Binary files differ
diff --git a/tests/figma-baselines/breadcrumb-row-59.png b/tests/figma-baselines/breadcrumb-row-59.png
new file mode 100644
index 0000000..7be207d
--- /dev/null
+++ b/tests/figma-baselines/breadcrumb-row-59.png
Binary files differ
diff --git a/tests/figma-baselines/breadcrumbs-baseline.png b/tests/figma-baselines/breadcrumbs-baseline.png
new file mode 100644
index 0000000..52c7137
--- /dev/null
+++ b/tests/figma-baselines/breadcrumbs-baseline.png
Binary files differ
diff --git a/tests/figma-baselines/buttons-baseline.png b/tests/figma-baselines/buttons-baseline.png
new file mode 100644
index 0000000..3285438
--- /dev/null
+++ b/tests/figma-baselines/buttons-baseline.png
Binary files differ
diff --git a/tests/figma-baselines/divider-baseline.png b/tests/figma-baselines/divider-baseline.png
new file mode 100644
index 0000000..38e3b83
--- /dev/null
+++ b/tests/figma-baselines/divider-baseline.png
Binary files differ
diff --git a/tests/figma-baselines/forms-baseline.png b/tests/figma-baselines/forms-baseline.png
new file mode 100644
index 0000000..75a75b5
--- /dev/null
+++ b/tests/figma-baselines/forms-baseline.png
Binary files differ
diff --git a/tests/figma-match.spec.js b/tests/figma-match.spec.js
new file mode 100644
index 0000000..cea32ef
--- /dev/null
+++ b/tests/figma-match.spec.js
@@ -0,0 +1,135 @@
+/**
+ * Figma frame comparison tests.
+ *
+ * For each matched demo/figma page, take a screenshot of the component matrix
+ * and compare it to the exported Figma PNG using pixelmatch.
+ */
+
+const { test, expect } = require("@playwright/test");
+const fs = require("fs");
+const path = require("path");
+const PNG = require("pngjs").PNG;
+const pixelmatch = require("pixelmatch").default || require("pixelmatch");
+const sharp = require("sharp");
+
+const BASELINES_DIR = path.join(__dirname, "..", "tests", "figma-baselines");
+const DIFFS_DIR = path.join(__dirname, "..", "tests", "figma-diffs");
+
+const COMPARISONS = [
+ { name: "buttons", baseline: "buttons-baseline.png", selector: "#figma-buttons", viewport: { width: 1200, height: 2372 }, threshold: 0.15 },
+ { name: "alerts", baseline: "alerts-baseline.png", selector: "#figma-alerts", viewport: { width: 1200, height: 1363 }, threshold: 0.20 },
+ { name: "badges", baseline: "badges-baseline.png", selector: "#figma-badges", viewport: { width: 1200, height: 743 }, threshold: 0.20 },
+ { name: "forms", baseline: "forms-baseline.png", selector: "#figma-forms", viewport: { width: 1200, height: 2648 }, threshold: 0.25 },
+ { name: "breadcrumbs", baseline: "breadcrumbs-baseline.png", selector: "#figma-breadcrumbs", viewport: { width: 1200, height: 567 }, threshold: 0.30, background: "#000000" },
+ { name: "divider", baseline: "divider-baseline.png", selector: "#figma-divider", viewport: { width: 1200, height: 120 }, threshold: 0.05, background: "#ffffff" },
+ { name: "attached-file", baseline: "attached-file-baseline.png", selector: "#figma-attached-file", viewport: { width: 560, height: 2000 }, threshold: 0.20, background: "#000000" }
+];
+
+/**
+ * Resize an image to the target dimensions using contain (preserve aspect
+ * ratio) and pad any leftover space with a solid background color. The source
+ * is composited on top of the pad color so transparent/semi-transparent pixels
+ * in exported Figma frames are flattened consistently with the demo page.
+ */
+async function normalizeToCanvas(imagePath, targetWidth, targetHeight, padColor = "#ffffff") {
+ const { data, info } = await sharp(imagePath)
+ .ensureAlpha()
+ .resize(targetWidth, targetHeight, { fit: "inside" })
+ .raw()
+ .toBuffer({ resolveWithObject: true });
+
+ const { width, height } = info;
+ const canvas = Buffer.alloc(targetWidth * targetHeight * 4);
+
+ // Parse pad color
+ const hex = padColor.replace("#", "");
+ const pr = parseInt(hex.substring(0, 2), 16);
+ const pg = parseInt(hex.substring(2, 4), 16);
+ const pb = parseInt(hex.substring(4, 6), 16);
+
+ // Fill canvas with pad color
+ for (let i = 0; i < targetWidth * targetHeight; i++) {
+ canvas[i * 4] = pr;
+ canvas[i * 4 + 1] = pg;
+ canvas[i * 4 + 2] = pb;
+ canvas[i * 4 + 3] = 255;
+ }
+
+ // Center source image and composite it over the pad color
+ const offsetX = Math.round((targetWidth - width) / 2);
+ const offsetY = Math.round((targetHeight - height) / 2);
+
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const srcIdx = (y * width + x) * 4;
+ const dstIdx = ((y + offsetY) * targetWidth + (x + offsetX)) * 4;
+ const srcA = data[srcIdx + 3] / 255;
+ const invA = 1 - srcA;
+ canvas[dstIdx] = Math.round(pr * invA + data[srcIdx] * srcA);
+ canvas[dstIdx + 1] = Math.round(pg * invA + data[srcIdx + 1] * srcA);
+ canvas[dstIdx + 2] = Math.round(pb * invA + data[srcIdx + 2] * srcA);
+ canvas[dstIdx + 3] = 255;
+ }
+ }
+
+ return { data: canvas, width: targetWidth, height: targetHeight };
+}
+
+async function compareWithBaseline(screenshotPath, baselinePath, diffPath, targetWidth, targetHeight, background) {
+ const screenshot = await normalizeToCanvas(screenshotPath, targetWidth, targetHeight, background);
+ const baseline = await normalizeToCanvas(baselinePath, targetWidth, targetHeight, background);
+
+ const diff = Buffer.alloc(targetWidth * targetHeight * 4);
+ const mismatched = pixelmatch(
+ screenshot.data,
+ baseline.data,
+ diff,
+ targetWidth,
+ targetHeight,
+ { threshold: 0.1, includeAA: false }
+ );
+
+ await sharp(diff, {
+ raw: { width: targetWidth, height: targetHeight, channels: 4 }
+ }).png().toFile(diffPath);
+
+ const totalPixels = targetWidth * targetHeight;
+ return { mismatched, ratio: mismatched / totalPixels };
+}
+
+test.describe("@figma", () => {
+ for (const comparison of COMPARISONS) {
+ test(`${comparison.name} matches Figma frame`, async ({ page }) => {
+ if (comparison.viewport) {
+ await page.setViewportSize(comparison.viewport);
+ }
+ await page.goto(`/figma/${comparison.name}.html`);
+ await page.waitForSelector(comparison.selector);
+
+ const screenshotDir = path.join(__dirname, "figma-screenshots");
+ fs.mkdirSync(screenshotDir, { recursive: true });
+ fs.mkdirSync(DIFFS_DIR, { recursive: true });
+
+ const screenshotPath = path.join(screenshotDir, `${comparison.name}.png`);
+ await page.locator(comparison.selector).screenshot({ path: screenshotPath });
+
+ const baselinePath = path.join(BASELINES_DIR, comparison.baseline);
+ if (!fs.existsSync(baselinePath)) {
+ test.info().annotations.push({ type: "skip", description: `Baseline ${comparison.baseline} not found` });
+ return;
+ }
+
+ const diffPath = path.join(DIFFS_DIR, `${comparison.name}-diff.png`);
+ const targetWidth = comparison.viewport?.width || 1200;
+ const targetHeight = comparison.viewport?.height || 1200;
+ const { ratio } = await compareWithBaseline(screenshotPath, baselinePath, diffPath, targetWidth, targetHeight, comparison.background);
+
+ test.info().annotations.push({
+ type: "info",
+ description: `Figma diff ratio: ${(ratio * 100).toFixed(2)}%`
+ });
+
+ expect(ratio).toBeLessThan(comparison.threshold || 0.05);
+ });
+ }
+});