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 @@ + + + + + + VMK UI Kit — Figma Alert Match + + + + + + + + +
+
+
+ +
+
Success
+ The operation completed successfully. +
+
+
+
+
+ +
+
Warning
+ Please review your settings before continuing. +
+
+
+
+
+ +
+
Error
+ Something went wrong. Please try again. +
+
+
+
+
+ +
+
Info
+ Here is some useful information. +
+
+
+
+
+ +
+
Primary
+ A primary alert with dark background. +
+
+
+
+
+ +
+
Secondary
+ A secondary alert with mint background. +
+
+
+
+
+ +
+
Accent
+ An accent alert with yellow background. +
+
+
+
+ + diff --git a/demo/figma/attached-file.html b/demo/figma/attached-file.html new file mode 100644 index 0000000..5b936c5 --- /dev/null +++ b/demo/figma/attached-file.html @@ -0,0 +1,81 @@ + + + + + + VMK UI Kit — Figma Attached File Match + + + + + + + + +
+
+
+ PDF + +
+
+
+ Title of the document file version 2.pdf +
+
+ +
+
+ JPG + +
+
+
+ image.jpg +
+
+ +
+
+ DOCX + +
+
+
+ Upload failed.docx +
+
+ +
+
+ ZIP + +
+
+
+ Retry archive.zip +
+
+
+ + diff --git a/demo/figma/backgrounds.html b/demo/figma/backgrounds.html new file mode 100644 index 0000000..a9c9c64 --- /dev/null +++ b/demo/figma/backgrounds.html @@ -0,0 +1,86 @@ + + + + + + VMK UI Kit — Figma Backgrounds Match + + + + + + + + +
+
+

Gradient

+
+
+
+ Desktop +
+
+
+ Tablet +
+
+
+ Mobile +
+
+
+ +
+

Shape

+
+
+
+ Desktop +
+
+
+ Tablet +
+
+
+ Mobile +
+
+
+
+ + diff --git a/demo/figma/badges.html b/demo/figma/badges.html new file mode 100644 index 0000000..3dd8c77 --- /dev/null +++ b/demo/figma/badges.html @@ -0,0 +1,67 @@ + + + + + + VMK UI Kit — Figma Badges Match + + + + + + + + +
+
+ Primary + Secondary + Accent + Success + Warning + Info + Danger + Owner +
+
+ Outline + Primary Outline + Secondary Outline + Accent Outline + Success Outline + Warning Outline + Info Outline + Danger Outline +
+
+ Primary Small + Secondary Small + Success Small + Danger Small +
+
+ Primary Large + Secondary Large + Success Large + Danger Large +
+
+ With icon + With icon + With icon + With icon +
+
+ + diff --git a/demo/figma/breadcrumbs.html b/demo/figma/breadcrumbs.html new file mode 100644 index 0000000..2d6b445 --- /dev/null +++ b/demo/figma/breadcrumbs.html @@ -0,0 +1,114 @@ + + + + + + VMK UI Kit — Figma Breadcrumbs Match + + + + + + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + diff --git a/demo/figma/buttons.html b/demo/figma/buttons.html new file mode 100644 index 0000000..7ea77ed --- /dev/null +++ b/demo/figma/buttons.html @@ -0,0 +1,294 @@ + + + + + + VMK UI Kit — Figma Button Match + + + + + + + + +
+
+

Primary Buttons

+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ +
+

Secondary Buttons

+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ +
+

Tertiary Buttons

+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ +
+

Destructive Buttons

+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ +
+

Destructive Secondary Buttons

+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ +
+

Destructive Tertiary Buttons

+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+ + + + + + + + +
+
+
+ + diff --git a/demo/figma/divider.html b/demo/figma/divider.html new file mode 100644 index 0000000..b27c2b4 --- /dev/null +++ b/demo/figma/divider.html @@ -0,0 +1,32 @@ + + + + + + VMK UI Kit — Figma Divider Match + + + + + + + + +
+
+
+ + diff --git a/demo/figma/forms.html b/demo/figma/forms.html new file mode 100644 index 0000000..3b73985 --- /dev/null +++ b/demo/figma/forms.html @@ -0,0 +1,87 @@ + + + + + + VMK UI Kit — Figma Forms Match + + + + + + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + diff --git a/demo/index.html b/demo/index.html index 163986b..cf677d7 100644 --- a/demo/index.html +++ b/demo/index.html @@ -9,6 +9,23 @@ +
@@ -38,6 +55,12 @@ @@include('./partials/tooltips.html') @@include('./partials/dropdowns.html') @@include('./partials/navigation-shell.html') + @@include('./partials/backgrounds.html') + @@include('./partials/breadcrumbs.html') + @@include('./partials/divider.html') + @@include('./partials/accordion.html') + @@include('./partials/stepper.html') + @@include('./partials/file-upload.html')
diff --git a/demo/partials/accordion.html b/demo/partials/accordion.html new file mode 100644 index 0000000..7e4d49c --- /dev/null +++ b/demo/partials/accordion.html @@ -0,0 +1,57 @@ +
+

Accordion

+ +
+
+ + + + Account details + + + +
+

Manage your profile, password and notification preferences.

+
+
+ +
+ + + + Billing + + + +
+

View invoices, update payment methods and manage subscriptions.

+
+
+ +
+ + + + Notifications + + + +
+

Choose how and when you want to be notified.

+
+
+
+ +

Disclosure

+
+
+ + Shipping options + + +
+

Standard, express and same-day delivery are available.

+
+
+
+
diff --git a/demo/partials/backgrounds.html b/demo/partials/backgrounds.html new file mode 100644 index 0000000..f38ffab --- /dev/null +++ b/demo/partials/backgrounds.html @@ -0,0 +1,60 @@ +
+

Backgrounds

+ +

Gradient

+
+
+
+ Desktop +
+
+
+ Tablet +
+
+
+ Mobile +
+
+ +

Shape

+
+
+
+ Desktop +
+
+
+ Tablet +
+
+
+ Mobile +
+
+ + +
diff --git a/demo/partials/breadcrumbs.html b/demo/partials/breadcrumbs.html new file mode 100644 index 0000000..74cbe26 --- /dev/null +++ b/demo/partials/breadcrumbs.html @@ -0,0 +1,40 @@ +
+

Breadcrumbs

+ +

Default

+ + +

Collapsed

+ + +

Inverted (dark surface)

+
+ +
+
diff --git a/demo/partials/buttons.html b/demo/partials/buttons.html index 8f80f25..ba81256 100644 --- a/demo/partials/buttons.html +++ b/demo/partials/buttons.html @@ -54,8 +54,21 @@

States

+ + + +
+
+ + + +
+
+ + +
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

+ +
+
+
+
+

Upload files

+

Attach documents, archives or images.

+
+ Optional +
+ + +
+
+ +

File attachment cards

+
+
+
+ PDF + +
+
PDF
+
+ Quarterly report.pdf + PDF / 1.2 MB +
+
+ +
+
+ JPG + +
+
+
+ Preview loading... +
+
+ +
+
+ DOCX + +
+
+
+ Upload failed.docx + DOCX / 0 B +
+
+ +
+
+ ZIP + +
+
+
+ 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

+ +
    +
  1. + +

    Cart

    +

    Items added and validated.

    +
  2. +
  3. + 2 +

    Shipping

    +

    Choose a delivery address.

    +
  4. +
  5. + 3 +

    Payment

    +

    Enter your payment details.

    +
  6. +
  7. + 4 +

    Review

    +

    Confirm and place the order.

    +
  8. +
+ +

Vertical

+
    +
  1. + +

    Upload

    +

    File added.

    +
  2. +
  3. + 2 +

    Process

    +

    Running conversion.

    +
  4. +
  5. + 3 +

    Done

    +
  6. +
+
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 ``; +} + +function separatorHtml(char = "/") { + return ``; +} + +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 ``; +} + +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); + }); + } +});