openvibers/openvibe.design Commits

Every glyph gets its own page, a PNG maker, the fonts and a ZIP, and a Font Awesome alternative page (#44)

OP OpenVibers committed 13 days ago · fd5f079a89 · 12 files, +945 −30

/icons/<name>: the glyph on its 24-unit grid, its Font Awesome names and
character, every way to use it, the glyphs near it, and ImageObject JSON-LD
with the MIT licence and acquireLicensePage. The PNG maker (size up to 1024
at 1-3x, colour or the mark's two, line weight, room, a ground in its own
colour) is a GET form that icon.js previews live; /icons/<name>.png and
.svg take the same choices, clamped so a URL never fails (resvg).

/icons/pack/<file>: WOFF2, TTF, OTF, the sprite, both style sheets, the code
points, and a ZIP of every SVG. /icons/font-awesome-alternative: how to
switch, the parity table and FAQ (FAQPage). Every glyph page is in the
sitemap with its images. Tiles on /icons link to their pages.

openvibe-shared v4.62.0 (was v4.61.0); @resvg/resvg-js 2.6.2.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex ca65bbd..74506a1 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "AGPL-3.0-only",
"dependencies": {
++ "@resvg/resvg-js": "^2.6.2",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
@@@ -16,7 +17,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.61.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"pg": "^8.23.1"
},
"devDependencies": {
@@@ -39,6 +40,233 @@
"devOptional": true,
"license": "Apache-2.0"
},
++ "node_modules/@resvg/resvg-js": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
++ "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
++ "license": "MPL-2.0",
++ "engines": {
++ "node": ">= 10"
++ },
++ "optionalDependencies": {
++ "@resvg/resvg-js-android-arm-eabi": "2.6.2",
++ "@resvg/resvg-js-android-arm64": "2.6.2",
++ "@resvg/resvg-js-darwin-arm64": "2.6.2",
++ "@resvg/resvg-js-darwin-x64": "2.6.2",
++ "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
++ "@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
++ "@resvg/resvg-js-linux-arm64-musl": "2.6.2",
++ "@resvg/resvg-js-linux-x64-gnu": "2.6.2",
++ "@resvg/resvg-js-linux-x64-musl": "2.6.2",
++ "@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
++ "@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
++ "@resvg/resvg-js-win32-x64-msvc": "2.6.2"
++ }
++ },
++ "node_modules/@resvg/resvg-js-android-arm-eabi": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
++ "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
++ "cpu": [
++ "arm"
++ ],
++ "license": "MPL-2.0",
++ "optional": true,
++ "os": [
++ "android"
++ ],
++ "engines": {
++ "node": ">= 10"
++ }
++ },
++ "node_modules/@resvg/resvg-js-android-arm64": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
++ "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "MPL-2.0",
++ "optional": true,
++ "os": [
++ "android"
++ ],
++ "engines": {
++ "node": ">= 10"
++ }
++ },
++ "node_modules/@resvg/resvg-js-darwin-arm64": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
++ "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "MPL-2.0",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "engines": {
++ "node": ">= 10"
++ }
++ },
++ "node_modules/@resvg/resvg-js-darwin-x64": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
++ "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "MPL-2.0",
++ "optional": true,
++ "os": [
++ "darwin"
++ ],
++ "engines": {
++ "node": ">= 10"
++ }
++ },
++ "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
++ "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
++ "cpu": [
++ "arm"
++ ],
++ "license": "MPL-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">= 10"
++ }
++ },
++ "node_modules/@resvg/resvg-js-linux-arm64-gnu": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
++ "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "MPL-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">= 10"
++ }
++ },
++ "node_modules/@resvg/resvg-js-linux-arm64-musl": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
++ "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
++ "cpu": [
++ "arm64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "MPL-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">= 10"
++ }
++ },
++ "node_modules/@resvg/resvg-js-linux-x64-gnu": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
++ "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "glibc"
++ ],
++ "license": "MPL-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">= 10"
++ }
++ },
++ "node_modules/@resvg/resvg-js-linux-x64-musl": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
++ "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
++ "cpu": [
++ "x64"
++ ],
++ "libc": [
++ "musl"
++ ],
++ "license": "MPL-2.0",
++ "optional": true,
++ "os": [
++ "linux"
++ ],
++ "engines": {
++ "node": ">= 10"
++ }
++ },
++ "node_modules/@resvg/resvg-js-win32-arm64-msvc": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
++ "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
++ "cpu": [
++ "arm64"
++ ],
++ "license": "MPL-2.0",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": ">= 10"
++ }
++ },
++ "node_modules/@resvg/resvg-js-win32-ia32-msvc": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
++ "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
++ "cpu": [
++ "ia32"
++ ],
++ "license": "MPL-2.0",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": ">= 10"
++ }
++ },
++ "node_modules/@resvg/resvg-js-win32-x64-msvc": {
++ "version": "2.6.2",
++ "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
++ "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
++ "cpu": [
++ "x64"
++ ],
++ "license": "MPL-2.0",
++ "optional": true,
++ "os": [
++ "win32"
++ ],
++ "engines": {
++ "node": ">= 10"
++ }
++ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@@ -1114,9 +1342,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.61.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.61.0",
-- "integrity": "sha512-VrDzJxlGvWZEH8O4TrhTli5tv9O9aaT7zllc5o88lSRizTjG2gVNDxDxOjRn38jkb6pkYSRCG9CZ+hFtH6eY3g==",
++ "version": "4.62.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
++ "integrity": "sha512-JlFI7rOngOwpRC3lZXRkKsOv4CM5boPJqhm7URMVXq9VMMczyf9FdOw9tn3017pzquMVYYc3fz63v1B1mhSxsw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 7de9669..b9d6d2e 100644
---- a/package.json
++++ b/package.json
@@@ -15,6 +15,7 @@
"author": "OpenVibers",
"license": "AGPL-3.0-only",
"dependencies": {
++ "@resvg/resvg-js": "^2.6.2",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
@@@ -22,7 +23,7 @@
"helmet": "^8.1.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.61.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.62.0",
"pg": "^8.23.1"
},
"engines": {
ddiff --git a/public/css/icons.css b/public/css/icons.css
iindex 6523c10..2a1aa18 100644
---- a/public/css/icons.css
++++ b/public/css/icons.css
@@@ -28,7 +28,7 @@
/* The grid. */
.ic-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:8px}
--.ic-tile{display:grid;justify-items:center;align-content:center;gap:10px;min-height:104px;padding:14px 6px 10px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:inherit;cursor:pointer;transition:border-color .15s,transform .15s,background .15s}
++.ic-tile{text-decoration:none;display:grid;justify-items:center;align-content:center;gap:10px;min-height:104px;padding:14px 6px 10px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font:inherit;cursor:pointer;transition:border-color .15s,transform .15s,background .15s}
.ic-tile svg{width:var(--ic-size);height:var(--ic-size)}
.ic-tile span{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted);font-size:12px}
.ic-tile:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border));transform:translateY(-1px)}
@@@ -81,16 +81,88 @@
.ic-draft-acts form{margin:0}
.ic-draft-acts .ic-btn{min-height:36px;padding:0 12px;font-size:13px}
--.ic-use{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:14px}
--.ic-use>div{min-width:0}
--.ic-use>div:first-child{grid-row:span 2}
--.ic-use pre{font-size:12.5px;white-space:pre;overflow-x:auto;padding-right:52px}
++.ic-use,.gp-use{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
++.ic-use>div,.gp-use>div{min-width:0}
++.ic-use pre,.gp-use pre,.fa-steps pre{font-size:12.5px;white-space:pre;overflow-x:auto;padding-right:52px}
++.ic-use h3,.gp-use h3{margin-top:0}
++
++/* Get them all: one card per file. */
++.ic-packs{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px}
++.ic-packs a{display:grid;gap:2px;height:100%;box-sizing:border-box;padding:14px 16px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);text-decoration:none;transition:border-color .15s}
++.ic-packs a:hover{border-color:color-mix(in srgb,var(--accent) 55%,var(--border))}
++.ic-packs a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
++.ic-packs b{font-family:var(--ic-face);font-weight:900;font-size:17px}
++.ic-packs small{color:var(--text-secondary);font-size:13px}
++.ic-packs code{margin-top:6px;color:var(--text-muted);font-size:12px}
++
++/* One glyph's page. */
++.gp-crumbs{display:flex;gap:8px;align-items:center;margin-bottom:18px;color:var(--text-muted);font-size:13.5px}
++.gp-crumbs a{color:var(--text-secondary);text-decoration:none}
++.gp-crumbs a:hover{color:var(--accent)}
++.gp-hero{display:grid;grid-template-columns:minmax(0,300px) minmax(0,1fr);gap:clamp(20px,4vw,48px);align-items:center;padding-bottom:clamp(18px,3vw,28px)}
++.gp-stage{margin:0;display:grid;gap:10px;justify-items:center}
++.gp-stage>svg{box-sizing:border-box;width:100%;max-width:300px;height:auto;aspect-ratio:1;padding:12%;border-radius:24px;border:1px solid var(--border);color:var(--text-primary);background-color:var(--bg-secondary);background-image:linear-gradient(color-mix(in srgb,var(--text-muted) 16%,transparent) 1px,transparent 1px),linear-gradient(90deg,color-mix(in srgb,var(--text-muted) 16%,transparent) 1px,transparent 1px);background-origin:content-box;background-size:calc(100% / 24) calc(100% / 24)}
++.gp-stage figcaption{color:var(--text-muted);font-size:12px}
++.gp-h1{margin:0;font-family:var(--ic-face);font-weight:900;font-size:clamp(36px,6vw,64px);line-height:1;letter-spacing:-.02em;overflow-wrap:anywhere}
++.gp-lede{margin:12px 0 0;max-width:58ch;color:var(--text-secondary);font-size:clamp(15px,1.6vw,17px);line-height:1.6}
++.gp-lede code{font-size:.88em}
++.gp-facts{display:flex;flex-wrap:wrap;gap:8px 22px;margin:16px 0 0}
++.gp-facts div{display:grid;gap:2px}
++.gp-facts dt{color:var(--text-muted);font-size:11.5px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}
++.gp-facts dd{margin:0;font-size:14px}
++.gp-sizes{display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px 26px;padding:18px 20px;margin-bottom:6px;border-radius:16px;border:1px solid var(--border);background:var(--bg-secondary)}
++.gp-sizes span{display:inline-flex;align-items:center;gap:.25em;line-height:1;color:var(--text-primary)}
++.gp-sizes svg{width:1.15em;height:1.15em}
++.gp-sizes i{font-style:normal;font-weight:650}
++.gp-sizes small{align-self:flex-end;margin-left:4px;color:var(--text-muted);font-size:11px;font-variant-numeric:tabular-nums}
++
++/* The PNG maker. */
++.gp-maker{display:grid;grid-template-columns:minmax(0,320px) minmax(0,1fr);gap:18px;align-items:start}
++.gp-preview{display:grid;place-items:center;aspect-ratio:1;border-radius:18px;border:1px solid var(--border);
++ background-color:#fff;background-image:linear-gradient(45deg,#e8ebf2 25%,transparent 25%),linear-gradient(-45deg,#e8ebf2 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#e8ebf2 75%),linear-gradient(-45deg,transparent 75%,#e8ebf2 75%);
++ background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0}
++.gp-preview img{max-width:80%;height:auto;image-rendering:auto}
++.gp-controls{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 14px}
++.gp-field{display:grid;gap:6px;min-width:0;font-size:12.5px;font-weight:700;color:var(--text-secondary)}
++.gp-field>span:first-child{display:flex;justify-content:space-between;gap:8px}
++.gp-field output{color:var(--text-primary);font-variant-numeric:tabular-nums}
++.gp-field select,.gp-field input[type=text]{min-height:42px;box-sizing:border-box;width:100%;padding:0 12px;border-radius:10px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:15px;font-weight:500}
++.gp-field input[type=range]{width:100%;min-height:32px;accent-color:var(--accent)}
++.gp-colour{display:flex;gap:6px;align-items:center}
++.gp-colour input[type=color]{flex:none;width:42px;height:42px;padding:3px;border-radius:10px;border:1px solid var(--border);background:var(--bg-primary);cursor:pointer}
++.gp-colour input[type=text]{flex:1;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
++.gp-chip{flex:none;min-height:42px;padding:0 12px;border-radius:10px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-secondary);font:inherit;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap}
++.gp-chip[aria-pressed=true]{border-color:transparent;color:#fff;background:linear-gradient(135deg,#4f7cff,#8b5cf6)}
++.gp-go{grid-column:1/-1;display:flex;flex-wrap:wrap;gap:8px;padding-top:4px}
++
++/* The Font Awesome alternative. */
++.fa-steps{margin:0;padding:0;list-style:none;counter-reset:s;display:grid;gap:12px;max-width:760px}
++.fa-steps li{min-width:0;counter-increment:s;position:relative;padding:14px 16px 14px 56px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary);line-height:1.55}
++.fa-steps li::before{content:counter(s);position:absolute;left:16px;top:13px;display:grid;place-items:center;width:28px;height:28px;border-radius:50%;background:var(--accent-strong,var(--accent));color:var(--on-accent-strong,#fff);font-family:var(--ic-face);font-weight:900}
++.fa-steps li>b{display:block;margin-bottom:6px}
++.fa-table{display:grid;border:1px solid var(--border);border-radius:16px;overflow:hidden}
++.fa-table>div{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,2fr) auto;gap:14px;align-items:center;padding:12px 16px;border-top:1px solid var(--border)}
++.fa-table>div:first-child{border-top:0}
++.fa-what{font-weight:700}
++.fa-how{color:var(--text-secondary);font-size:14px;line-height:1.5}
++.fa-how code{font-size:12.5px;white-space:nowrap}
++.fa-mark{justify-self:end;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:750;white-space:nowrap;background:var(--bg-primary);color:var(--text-secondary)}
++.fa-mark.is-ok{color:var(--success,#16a34a);background:color-mix(in srgb,var(--success,#16a34a) 14%,transparent)}
++.fa-mark.is-ours{color:var(--accent);background:color-mix(in srgb,var(--accent) 14%,transparent)}
++.fa-faq{display:grid;gap:8px;max-width:760px}
++.fa-faq details{padding:12px 16px;border-radius:14px;border:1px solid var(--border);background:var(--bg-secondary)}
++.fa-faq summary{cursor:pointer;font-weight:700}
++.fa-faq p{margin:8px 0 0;color:var(--text-secondary);line-height:1.55}
@media (min-width:761px){.ic-tools{position:sticky;top:calc(var(--navbar-h,52px) + 8px);z-index:5}}
@media (max-width:760px){
-- .ic-sec-head,.ic-use{grid-template-columns:minmax(0,1fr)}
-- .ic-use>div:first-child{grid-row:auto}
++ .ic-sec-head,.ic-use,.gp-use,.gp-hero,.gp-maker{grid-template-columns:minmax(0,1fr)}
++ .gp-stage>svg{max-width:220px}
++ .gp-preview{max-width:320px}
++ .fa-table>div{grid-template-columns:minmax(0,1fr) auto}
++ .fa-how{grid-column:1/-1;grid-row:2}
.ic-grid{grid-template-columns:repeat(auto-fill,minmax(88px,1fr))}
.ic-tile{min-height:92px}
}
--@media (prefers-reduced-motion:reduce){.ic-tile{transition:none}.ic-tile:hover{transform:none}}
++@media (max-width:420px){.gp-controls{grid-template-columns:minmax(0,1fr)}}
++@media (prefers-reduced-motion:reduce){.ic-tile,.ic-packs a{transition:none}.ic-tile:hover{transform:none}}
ddiff --git a/public/js/icon.js b/public/js/icon.js
nnew file mode 100644
iindex 0000000..988bb53
---- /dev/null
++++ b/public/js/icon.js
@@@ -0,0 +1,82 @@
++/**
++ * One glyph's page (server/views/icon-page.js): the PNG maker previews as you change it, keeps the address in step
++ * (so the link rebuilds the same file), and Copy the SVG copies the plain drawing. Without this script the maker is a
++ * GET form and works the same, one submit at a time.
++ */
++(function () {
++ 'use strict';
++ const $ = (s, el = document) => el.querySelector(s);
++ const form = $('[data-gp-form]');
++ if (!form) return;
++ const name = location.pathname.split('/').pop();
++ const img = $('[data-gp-img]', form);
++ const pngLink = $('[data-gp-png]', form);
++ const svgLink = $('[data-gp-svg]', form);
++ const bgField = $('[data-gp-bg-field]', form);
++ const preview = $('[data-gp-preview]', form);
++ const field = (n) => form.elements[n];
++ const pick = $('[data-gp-colour]', form);
++ const bgPick = $('[data-gp-bgpick]', form);
++ const DEFAULT = { size: '256', scale: '1', color: '0f172a', stroke: '2', pad: '0', ground: 'none', bg: 'ffffff' };
++
++ function query() {
++ const p = new URLSearchParams();
++ Object.keys(DEFAULT).forEach((k) => {
++ const v = String(field(k).value).replace(/^#/, '').toLowerCase();
++ if (v !== DEFAULT[k] && !(k === 'bg' && field('ground').value === 'none')) p.set(k, v);
++ });
++ const s = p.toString();
++ return s ? `?${s}` : '';
++ }
++ let timer = 0;
++ function update() {
++ form.querySelectorAll('[data-gp-out]').forEach((o) => { o.textContent = field(o.dataset.gpOut).value + (o.dataset.gpOut === 'pad' ? '%' : ''); });
++ bgField.hidden = field('ground').value === 'none';
++ preview.dataset.ground = field('ground').value;
++ $('[data-gp-mark]', form).setAttribute('aria-pressed', field('color').value === 'mark' ? 'true' : 'false');
++ // A colour typed as hex shows in its swatch too.
++ [[field('color'), pick], [field('bg'), bgPick]].forEach(([text, swatch]) => {
++ const v = String(text.value).replace(/^#/, '');
++ if (/^[0-9a-f]{6}$/i.test(v)) swatch.value = `#${v.toLowerCase()}`;
++ else if (/^[0-9a-f]{3}$/i.test(v)) swatch.value = `#${v.replace(/./g, (c) => c + c).toLowerCase()}`;
++ });
++ clearTimeout(timer);
++ timer = setTimeout(() => {
++ const q = query();
++ const size = Number(field('size').value), px = size * Number(field('scale').value);
++ img.src = `/icons/${name}.png${q}`;
++ img.width = img.height = Math.min(size, 256);
++ img.alt = `${name} icon, ${size} pixels`;
++ pngLink.href = `/icons/${name}.png${q}`;
++ pngLink.setAttribute('download', `${name}-${px}.png`);
++ pngLink.lastChild.textContent = ` PNG, ${px} px`;
++ svgLink.href = `/icons/${name}.svg${q}`;
++ history.replaceState(null, '', `${location.pathname}${q}#png`);
++ }, 120);
++ }
++ form.addEventListener('input', update);
++ form.addEventListener('change', update);
++ form.addEventListener('submit', (e) => { e.preventDefault(); update(); });
++
++ // Colour pickers write the hex fields; "The mark's" switches to the mark's two colours and back.
++ pick.addEventListener('input', () => { field('color').value = pick.value.slice(1); update(); });
++ bgPick.addEventListener('input', () => { field('bg').value = bgPick.value.slice(1); update(); });
++ let lastColour = field('color').value === 'mark' ? DEFAULT.color : field('color').value;
++ $('[data-gp-mark]', form).addEventListener('click', () => {
++ if (field('color').value === 'mark') field('color').value = lastColour;
++ else { lastColour = field('color').value; field('color').value = 'mark'; }
++ update();
++ });
++
++ // Copy the plain SVG.
++ const copy = document.querySelector('[data-gp-copy]');
++ if (copy && navigator.clipboard) {
++ copy.hidden = false;
++ copy.addEventListener('click', () => {
++ fetch(copy.dataset.gpCopy).then((r) => r.text()).then((t) => navigator.clipboard.writeText(t)).then(() => {
++ copy.textContent = 'Copied';
++ setTimeout(() => { copy.textContent = 'Copy the SVG'; }, 1400);
++ }, () => {});
++ });
++ }
++})();
ddiff --git a/public/js/icons.js b/public/js/icons.js
iindex a69c015..8a1cedb 100644
---- a/public/js/icons.js
++++ b/public/js/icons.js
@@@ -1,7 +1,7 @@
/**
* /icons in the browser (server/views/icons.js): find a glyph by its name or a Font Awesome name as you type, see the
-- * grid at 16, 20, 24 or 32 px, open one large (with what it stands for, Copy the SVG and Download), and Escape or the
-- * close button shuts it. Without this script the grid and the forms still work.
++ * grid at 16, 20, 24 or 32 px, open one large (with what it stands for, Copy the SVG, SVG, PNG and its page), and
++ * Escape or the close button shuts it. Without this script every tile is a link to its glyph's page.
*/
(function () {
'use strict';
@@@ -42,7 +42,9 @@
const standalone = (name) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${svgOf(name)}</svg>`;
grid.addEventListener('click', (e) => {
const t = e.target.closest('.ic-tile');
-- if (!t || !sheet || typeof sheet.showModal !== 'function') return;
++ // A tile is a link to the glyph's page: a plain click opens it here large, a new-tab click follows the link.
++ if (!t || !sheet || typeof sheet.showModal !== 'function' || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
++ e.preventDefault();
current = t.dataset.name;
$('[data-ic-sheet-name]', sheet).textContent = current;
const big = $('[data-ic-sheet-big]', sheet);
@@@ -60,6 +62,9 @@
} else meta.append('No Font Awesome name maps to it.');
const dl = $('[data-ic-download]', sheet);
dl.href = `/icons/${encodeURIComponent(current)}.svg`; dl.setAttribute('download', `${current}.svg`);
++ const png = $('[data-ic-png]', sheet);
++ png.href = `/icons/${encodeURIComponent(current)}.png?size=512`; png.setAttribute('download', `${current}-512.png`);
++ $('[data-ic-page]', sheet).href = `/icons/${encodeURIComponent(current)}`;
sheet.showModal();
});
if (sheet) {
ddiff --git a/server/glyphs/export.js b/server/glyphs/export.js
nnew file mode 100644
iindex 0000000..6ddbbd3
---- /dev/null
++++ b/server/glyphs/export.js
@@@ -0,0 +1,156 @@
++'use strict';
++/**
++ * A glyph as a file (plan L32 I5): a standalone SVG with the person's choices, a PNG drawn from it, every glyph as a
++ * ZIP of SVGs, and the font files openvibe-shared builds. The choices are the PNG maker's on /icons/<name>: size,
++ * density, colour (or the mark's two), stroke weight, padding, and a ground (none, a circle, a square or a squircle)
++ * in its own colour. Every value is clamped; anything unknown falls back to the default, so a URL never fails.
++ *
++ * options(query) → { size, scale, color, stroke, pad, ground, bg }, and isDefault(opts)
++ * query(opts) → the query string that rebuilds the same file (only what differs from the default)
++ * svg(name, opts) → standalone SVG text, or null for an unknown glyph
++ * png(name, opts) → a PNG Buffer (resvg), or null
++ * zip() → every glyph as <name>.svg plus LICENSE.txt, deflated
++ * PACK → the downloads openvibe-shared ships: { file: { path, type, label, about } }
++ */
++const fs = require('fs');
++const path = require('path');
++const zlib = require('zlib');
++const glyphs = require('openvibe-shared/glyphs');
++
++const SHARED = path.dirname(require.resolve('openvibe-shared/glyphs'));
++const MARK = ['#4f7cff', '#8b5cf6'];
++const GROUNDS = ['none', 'circle', 'square', 'squircle'];
++const DEFAULT = Object.freeze({ size: 256, scale: 1, color: '0f172a', stroke: 2, pad: 0, ground: 'none', bg: 'ffffff' });
++const SIZES = [16, 24, 32, 48, 64, 96, 128, 192, 256, 512, 1024];
++
++const hex = (v, d) => (/^(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(v || '').replace(/^#/, '')) ? String(v).replace(/^#/, '').toLowerCase() : d);
++const num = (v, lo, hi, d, step = 1) => {
++ const n = Number(v);
++ return Number.isFinite(n) && v !== '' && v != null ? Math.min(hi, Math.max(lo, Math.round(n / step) * step)) : d;
++};
++
++function options(q = {}) {
++ return {
++ size: num(q.size, 16, 1024, DEFAULT.size),
++ scale: num(q.scale, 1, 3, DEFAULT.scale),
++ color: String(q.color || '').toLowerCase() === 'mark' ? 'mark' : hex(q.color, DEFAULT.color),
++ stroke: num(q.stroke, 1, 3, DEFAULT.stroke, 0.25),
++ pad: num(q.pad, 0, 40, DEFAULT.pad),
++ ground: GROUNDS.includes(String(q.ground)) ? String(q.ground) : DEFAULT.ground,
++ bg: hex(q.bg, DEFAULT.bg),
++ };
++}
++const isDefault = (o) => Object.keys(DEFAULT).every((k) => o[k] === DEFAULT[k]);
++function query(o) {
++ const p = new URLSearchParams();
++ for (const k of Object.keys(DEFAULT)) if (o[k] !== DEFAULT[k] && !(k === 'bg' && o.ground === 'none')) p.set(k, String(o[k]));
++ const s = p.toString();
++ return s ? `?${s}` : '';
++}
++
++/** The glyph inside a larger box: padding is a share of the whole, the ground fills the box behind it. */
++function svg(name, o = options()) {
++ const inner = glyphs.path(name);
++ if (!inner) return null;
++ const box = +(24 / (1 - (2 * o.pad) / 100)).toFixed(3);
++ const off = +((box - 24) / 2).toFixed(3);
++ const px = o.size;
++ const ground = o.ground === 'circle' ? `<circle cx="${box / 2}" cy="${box / 2}" r="${box / 2}" fill="#${o.bg}"/>`
++ : o.ground === 'square' ? `<rect width="${box}" height="${box}" fill="#${o.bg}"/>`
++ : o.ground === 'squircle' ? `<rect width="${box}" height="${box}" rx="${+(box * 0.225).toFixed(3)}" fill="#${o.bg}"/>` : '';
++ const stroke = o.color === 'mark' ? 'url(#ovg-mark)' : `#${o.color}`;
++ const defs = o.color === 'mark' ? `<defs><linearGradient id="ovg-mark" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="24" y2="24"><stop offset="0" stop-color="${MARK[0]}"/><stop offset="1" stop-color="${MARK[1]}"/></linearGradient></defs>` : '';
++ const drawn = name === 'busy' ? inner.replace(/stroke-width="[^"]*"/, `stroke-width="${+(3.4 * o.stroke / 2).toFixed(2)}"`) : inner;
++ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${box} ${box}" width="${px}" height="${px}">`
++ + `<title>${name}</title>${defs}${ground}`
++ + `<g transform="translate(${off} ${off})" fill="none" stroke="${stroke}" stroke-width="${o.stroke}" stroke-linecap="round" stroke-linejoin="round">${drawn}</g></svg>`;
++}
++
++// PNGs are drawn once per set of choices and kept for a while (a page shows a few, people tweak one).
++const pngCache = new Map();
++let Resvg = null;
++function png(name, o = options()) {
++ const key = `${name}${query(o)}`;
++ if (pngCache.has(key)) return pngCache.get(key);
++ const src = svg(name, o);
++ if (!src) return null;
++ if (!Resvg) ({ Resvg } = require('@resvg/resvg-js'));
++ const out = new Resvg(src, { fitTo: { mode: 'width', value: o.size * o.scale }, font: { loadSystemFonts: false } }).render().asPng();
++ pngCache.set(key, out);
++ if (pngCache.size > 300) pngCache.delete(pngCache.keys().next().value);
++ return out;
++}
++
++// ── A small ZIP writer (deflate, one central directory): enough for a folder of SVGs. ──
++const CRC = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
++function crc32(buf) { let c = 0xFFFFFFFF; for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; }
++function zipOf(entries) {
++ const DOS_TIME = 0, DOS_DATE = ((2026 - 1980) << 9) | (10 << 5) | 31; // a fixed date: the same glyphs give the same bytes
++ const locals = [], central = [];
++ let offset = 0;
++ for (const { name, data } of entries) {
++ const raw = Buffer.from(data);
++ const packed = zlib.deflateRawSync(raw, { level: 9 });
++ const nameBuf = Buffer.from(name, 'utf8');
++ const crc = crc32(raw);
++ const local = Buffer.alloc(30);
++ local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(8, 8);
++ local.writeUInt16LE(DOS_TIME, 10); local.writeUInt16LE(DOS_DATE, 12); local.writeUInt32LE(crc, 14);
++ local.writeUInt32LE(packed.length, 18); local.writeUInt32LE(raw.length, 22); local.writeUInt16LE(nameBuf.length, 26); local.writeUInt16LE(0, 28);
++ locals.push(local, nameBuf, packed);
++ const dir = Buffer.alloc(46);
++ dir.writeUInt32LE(0x02014b50, 0); dir.writeUInt16LE(20, 4); dir.writeUInt16LE(20, 6); dir.writeUInt16LE(0x0800, 8); dir.writeUInt16LE(8, 10);
++ dir.writeUInt16LE(DOS_TIME, 12); dir.writeUInt16LE(DOS_DATE, 14); dir.writeUInt32LE(crc, 16); dir.writeUInt32LE(packed.length, 20);
++ dir.writeUInt32LE(raw.length, 24); dir.writeUInt16LE(nameBuf.length, 28); dir.writeUInt32LE(offset, 42);
++ central.push(dir, nameBuf);
++ offset += 30 + nameBuf.length + packed.length;
++ }
++ const dirSize = central.reduce((a, b) => a + b.length, 0);
++ const end = Buffer.alloc(22);
++ end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
++ end.writeUInt32LE(dirSize, 12); end.writeUInt32LE(offset, 16);
++ return Buffer.concat([...locals, ...central, end]);
++}
++
++const LICENSE = `The OpenVibe glyphs — MIT License
++
++Copyright (c) 2026 OpenVibe
++
++Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
++documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
++rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
++persons to whom the Software is furnished to do so, subject to the following conditions:
++
++The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
++Software.
++
++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
++WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
++COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
++OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
++
++https://openvibe.design/icons
++`;
++let zipped = null;
++function zip() {
++ if (!zipped) zipped = zipOf([...glyphs.NAMES.map((n) => ({ name: `ov-glyphs/${n}.svg`, data: glyphs.svg(n, { size: 24 }) })), { name: 'ov-glyphs/LICENSE.txt', data: LICENSE }]);
++ return zipped;
++}
++
++/** What openvibe-shared ships beside the glyphs, offered as downloads (/icons/pack/<file>). */
++const PACK = Object.freeze({
++ 'ov-glyphs.woff2': { path: path.join(SHARED, 'ov-glyphs.woff2'), type: 'font/woff2', label: 'WOFF2', about: 'the web font' },
++ 'ov-glyphs.ttf': { path: path.join(SHARED, 'glyph-fonts', 'ov-glyphs.ttf'), type: 'font/ttf', label: 'TTF', about: 'TrueType, for desktop apps' },
++ 'ov-glyphs.otf': { path: path.join(SHARED, 'glyph-fonts', 'ov-glyphs.otf'), type: 'font/otf', label: 'OTF', about: 'OpenType, for design tools' },
++ 'ov-glyphs.svg': { path: path.join(SHARED, 'ov-glyphs.svg'), type: 'image/svg+xml', label: 'SVG sprite', about: 'every glyph as a <symbol>' },
++ 'ov-glyphs-font.css': { path: path.join(SHARED, 'ov-glyphs-font.css'), type: 'text/css', label: 'Font CSS', about: 'class="ovi ovi-eye"' },
++ 'ov-glyphs-fa.css': { path: path.join(SHARED, 'ov-glyphs-fa.css'), type: 'text/css', label: 'Font Awesome names', about: 'class="fa-solid fa-eye" on the font' },
++ 'glyph-codepoints.json': { path: path.join(SHARED, 'glyph-codepoints.json'), type: 'application/json', label: 'Code points', about: 'each glyph\'s character' },
++});
++let codepoints = null;
++function codepointOf(name) {
++ if (!codepoints) { try { codepoints = JSON.parse(fs.readFileSync(PACK['glyph-codepoints.json'].path, 'utf8')).glyphs; } catch { codepoints = {}; } }
++ return codepoints[name] || null;
++}
++
++module.exports = { options, isDefault, query, svg, png, zip, zipOf, crc32, PACK, codepointOf, DEFAULT, SIZES, GROUNDS, MARK };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex fb3e682..125ae58 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -10,6 +10,7 @@
const fs = require('fs');
const path = require('path');
const seo = require('openvibe-shared/seo');
++const glyphs = require('openvibe-shared/glyphs');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Design';
@@@ -34,6 +35,7 @@ function publicPages(themes = []) {
{ path: '/tokens', changefreq: 'weekly', priority: 0.8, title: 'Your theme, as code', text: 'Any OpenVibe theme as design tokens: CSS variables, Tailwind, Jetpack Compose, SwiftUI, Flutter, Figma variables and W3C DTCG JSON, light and dark together.' },
{ path: '/brand', changefreq: 'monthly', priority: 0.7, title: 'The OpenVibe mark', text: 'The OpenVibe mark, a ring O and a round V: what each of its moves means, its forms, colours and the names beside it, with the SVG files and the code to use it on any site.' },
{ path: '/icons', changefreq: 'weekly', priority: 0.7, title: 'The OpenVibe glyphs', text: 'Every OpenVibe icon in one hand, the mark\'s: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.' },
++ { path: '/icons/font-awesome-alternative', changefreq: 'monthly', priority: 0.6, title: 'A free Font Awesome alternative', text: 'Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts.' },
{ path: '/arena', changefreq: 'daily', priority: 0.9, title: 'The Design Arena', text: 'One prompt, three AI designers, blind votes and an Elo leaderboard per category, with cost and time per generation.' },
];
const themePages = (Array.isArray(themes) ? themes : []).filter((t) => t && t.slug).map((t) => ({
@@@ -43,7 +45,13 @@ function publicPages(themes = []) {
title: String(t.name || t.slug),
text: `${t.mode ? `${String(t.mode)} theme. ` : ''}${String(t.description || 'A theme in the OpenVibe catalogue.')}`.trim(),
}));
-- return [...static_, ...themePages];
++ // One page per glyph, with its SVG and a PNG as images (Google Images reads the licence from the page's JSON-LD).
++ const glyphPages = glyphs.NAMES.map((n) => ({
++ path: `/icons/${n}`, changefreq: 'monthly', priority: 0.5, title: `${n} icon`,
++ text: `The ${n.replace(/-/g, ' ')} icon from the OpenVibe glyphs: SVG, PNG at any size and colour, and the font.`,
++ images: [`/icons/${n}.svg`, `/icons/${n}.png?size=512`],
++ }));
++ return [...static_, ...themePages, ...glyphPages];
}
function createDiscoveryRoutes(ctx) {
@@@ -74,7 +82,8 @@ function createDiscoveryRoutes(ctx) {
{ title: 'OpenVibe.Design', url: abs('/'), note: 'the theme catalogue' },
{ title: 'The studio', url: abs('/studio'), note: 'try, edit, use, submit and roll back a theme' },
{ title: 'Tokens', url: abs('/tokens'), note: 'any theme as CSS, Tailwind, Compose (Android), SwiftUI (iOS), Flutter, Figma or DTCG JSON; the API is GET /api/v1/tokens/:theme?format=' },
-- { title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand; the registry as data at GET /api/v1/glyphs; openvibe-shared/glyphs and ov-glyphs.js' },
++ { title: 'The OpenVibe glyphs', url: abs('/icons'), note: 'every icon in one hand, each with its own page (/icons/<name>): SVG, PNG at any size and colour (/icons/<name>.png?size=&color=&stroke=&pad=&ground=&bg=), the WOFF2/TTF/OTF fonts and the sprite (/icons/pack/<file>); the registry as data at GET /api/v1/glyphs' },
++ { title: 'A free Font Awesome alternative', url: abs('/icons/font-awesome-alternative'), note: 'one script keeps Font Awesome markup, classes, animations and data-fa-transform, and shows the OpenVibe glyphs' },
{ title: 'The OpenVibe mark', url: abs('/brand'), note: 'the logo, its states and motion, colours, the brand face, SVG files and code (openvibe-shared/mark, ov-mark.js)' },
{ title: 'The Design Arena', url: abs('/arena'), note: 'one prompt, three AI designers, blind votes and an Elo leaderboard' },
{ title: 'For developers', url: abs('/developers'), note: 'the theme API proxy' },
@@@ -98,7 +107,7 @@ function createDiscoveryRoutes(ctx) {
r.get('/sitemap.xml', async (_req, res) => {
const pages = publicPages(await themes());
const lastmod = siteUpdated();
-- const urls = pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
++ const urls = pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority, ...(e.images ? { images: e.images.map(abs) } : {}) }));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
ddiff --git a/server/http/icons.js b/server/http/icons.js
iindex cbbc9b2..95c3b40 100644
---- a/server/http/icons.js
++++ b/server/http/icons.js
@@@ -2,7 +2,13 @@
/**
* The OpenVibe glyphs on openvibe.design (plan L32):
* GET /icons the set, what is still Font Awesome, "Draw an icon" and the review list
-- * GET /icons/<name>.svg one glyph as a standalone SVG (anyone may fetch it)
++ * GET /icons/<name> one glyph's own page: every export, the PNG maker, its Font Awesome names
++ * GET /icons/<name>.svg one glyph as a standalone SVG (anyone may fetch it); with the PNG maker's
++ * choices in the query (size, color, stroke, pad, ground, bg), drawn with them
++ * GET /icons/<name>.png the same as a PNG (resvg), any size up to 1024 at 1-3x
++ * GET /icons/pack/<file> the fonts (WOFF2, TTF, OTF), the sprite, the CSS, the code points, and
++ * ov-glyphs-svg.zip with every glyph
++ * GET /icons/font-awesome-alternative how to switch, and what keeps working
* POST /icons/draft signed in: OpenVibe.AI drafts candidates (same-origin form)
* POST /icons/drafts/:id/review an admin approves or rejects one (same-origin form)
* GET /api/v1/glyphs the registry as data
@@@ -15,6 +21,8 @@ const { asyncRouter } = require('./router');
const { send, baseUrl } = require('../render/layout');
const { writeAllowed } = require('./same-origin');
const { iconsPage, glyphsJson } = require('../views/icons');
++const { glyphPage, faAlternativePage } = require('../views/icon-page');
++const ex = require('../glyphs/export');
const { createGlyphDrafts } = require('../glyphs/drafts');
function createIconRoutes(ctx) {
@@@ -39,11 +47,29 @@ function createIconRoutes(ctx) {
send(res, 200, iconsPage({ base: baseUrl(), mine, drafts: waiting, reviewer, notice, error: String(req.query.error || '').slice(0, 200), draw }), { viewer: req.viewer });
});
++ r.get('/icons/font-awesome-alternative', (req, res) => send(res, 200, faAlternativePage({ base: baseUrl() }), { viewer: req.viewer }));
++
++ r.get('/icons/pack/:file', (req, res, next) => {
++ const file = req.params.file;
++ res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*');
++ if (file === 'ov-glyphs-svg.zip') return res.type('application/zip').attachment(file).send(ex.zip());
++ const p = Object.prototype.hasOwnProperty.call(ex.PACK, file) && ex.PACK[file];
++ if (!p) return next();
++ res.type(p.type).attachment(file).sendFile(p.path, (err) => { if (err && !res.headersSent) next(); });
++ });
++
r.get('/icons/:file', (req, res, next) => {
-- const m = /^([a-z][a-z0-9-]{0,40})\.svg$/.exec(req.params.file);
-- const out = m && glyphs.svg(m[1], { size: 24, title: m[1] });
-- if (!out) return next();
-- res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*').type('image/svg+xml').send(out);
++ const m = /^([a-z][a-z0-9-]{0,40})(?:\.(svg|png))?$/.exec(req.params.file);
++ if (!m || !glyphs.path(m[1])) return next();
++ const [, name, kind] = m;
++ const opts = ex.options(req.query);
++ if (!kind) return send(res, 200, glyphPage({ base: baseUrl(), name, opts }), { viewer: req.viewer });
++ res.set('Cache-Control', 'public, max-age=86400').set('Access-Control-Allow-Origin', '*');
++ if (kind === 'svg') return res.type('image/svg+xml').send(ex.isDefault(opts) ? glyphs.svg(name, { size: 24, title: name }) : ex.svg(name, opts));
++ try { res.type('image/png').send(ex.png(name, opts)); } catch (err) {
++ log.warn('[OpenVibe.Design] glyph PNG failed:', err && err.message);
++ res.status(500).set('Cache-Control', 'no-store').type('text/plain').send('That PNG could not be drawn.');
++ }
});
r.post('/icons/draft', form, async (req, res) => {
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 00b1ad3..4811f7e 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -43,7 +43,7 @@ const baseUrl = () => BASE;
const canonical = (p) => `${BASE}${p || '/'}`;
/** The page around a body: the shared shell with the room's stylesheets and behaviour. */
--function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [] } = {}) {
++function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], image, imageAlt } = {}) {
const pathNow = path || active || '/';
canonical = canonical || (path ? `${BASE}${path}` : undefined);
const navbar = {
@@@ -68,6 +68,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
canonical,
robots,
jsonLd,
++ ...(image ? { image, imageAlt } : {}),
home: '/',
navLinks: NAV,
navbar,
ddiff --git a/server/views/icon-page.js b/server/views/icon-page.js
nnew file mode 100644
iindex 0000000..7e07482
---- /dev/null
++++ b/server/views/icon-page.js
@@@ -0,0 +1,191 @@
++'use strict';
++/**
++ * One glyph's own page, /icons/<name> (plan L32 I5/I7), and /icons/font-awesome-alternative.
++ *
++ * A glyph page is what a search for "<name> icon" should land on: the glyph on its 24-unit grid, the Font Awesome names
++ * it stands in for, every way to get it (SVG, PNG at any size and colour, the font, the sprite, the drop-in), and the
++ * glyphs near it. The PNG maker is a plain GET form, so it works without JavaScript; icon.js previews as you change it.
++ * ImageObject JSON-LD carries the licence (MIT) and where to get it, which is what earns an image the "Licensable" mark.
++ *
++ * glyphPage({ base, name, opts }) faAlternativePage({ base }) related(name, n)
++ */
++const kit = require('openvibe-shared/kit');
++const glyphs = require('openvibe-shared/glyphs');
++const { layout, esc } = require('../render/layout');
++const ex = require('../glyphs/export');
++const usage = require('../glyphs/fa-usage.json');
++
++const title = (name) => name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' ');
++const faOf = (name) => Object.entries(glyphs.FA).filter(([, g]) => g === name).map(([fa]) => fa).sort();
++const drawn = (name) => (glyphs.META[name] && /Opus/.test(glyphs.META[name].by) ? 'Drawn for the set' : 'From the kit');
++const glyph = (name, size = 24, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true" focusable="false">${glyphs.path(name)}</svg>`;
++
++/** The glyphs that share a word with this one (its name or its Font Awesome names), then its neighbours. */
++function related(name, n = 12) {
++ const words = (g) => new Set([g, ...faOf(g)].flatMap((s) => s.split('-')).filter((w) => w.length > 2));
++ const mine = words(name);
++ const scored = glyphs.NAMES.filter((g) => g !== name).map((g) => [g, [...words(g)].filter((w) => mine.has(w)).length]).filter(([, s]) => s > 0)
++ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).map(([g]) => g);
++ const i = glyphs.NAMES.indexOf(name);
++ const near = [...glyphs.NAMES.slice(i + 1), ...glyphs.NAMES.slice(0, i)].filter((g) => !scored.includes(g));
++ return [...scored, ...near].slice(0, n);
++}
++
++function glyphPage({ base = 'https://openvibe.design', name, opts = ex.options() }) {
++ const fa = faOf(name);
++ const cp = ex.codepointOf(name);
++ const T = title(name);
++ const q = ex.query(opts);
++ const svgUrl = `/icons/${name}.svg`;
++ const pngUrl = `/icons/${name}.png${q}`;
++ const faLine = fa.length ? `In place of Font Awesome&rsquo;s ${fa.slice(0, 6).map((f) => `<code>fa-${esc(f)}</code>`).join(', ')}${fa.length > 6 ? ` and ${fa.length - 6} more` : ''}.` : '';
++ const sel = (field, list, cur, label) => `<label class="gp-field"><span>${label}</span><select name="${field}" data-gp="${field}">${list.map(([v, l]) => `<option value="${esc(v)}"${String(v) === String(cur) ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>`;
++ const range = (field, min, max, step, cur, label, unit) => `<label class="gp-field"><span>${label} <output data-gp-out="${field}">${cur}${unit}</output></span><input type="range" name="${field}" min="${min}" max="${max}" step="${step}" value="${cur}" data-gp="${field}"></label>`;
++
++ const hero = `<nav class="gp-crumbs" aria-label="Breadcrumb"><a href="/icons">Icons</a><span aria-hidden="true">/</span><span>${esc(name)}</span></nav>
++<header class="gp-hero">
++ <figure class="gp-stage">${glyph(name, 168, 'gp-big')}<figcaption>On the 24-unit grid, strokes two units wide</figcaption></figure>
++ <div class="gp-say">
++ <p class="ic-eye">The OpenVibe glyphs</p>
++ <h1 class="gp-h1">${esc(T)}</h1>
++ <p class="gp-lede">A free ${esc(T.toLowerCase())} icon in the OpenVibe hand, as SVG, PNG and a font. ${faLine}</p>
++ <dl class="gp-facts">
++ <div><dt>Name</dt><dd><code>${esc(name)}</code></dd></div>
++ ${cp ? `<div><dt>Character</dt><dd><code>U+${esc(cp)}</code></dd></div>` : ''}
++ <div><dt>Drawn</dt><dd>${esc(drawn(name))}</dd></div>
++ <div><dt>Licence</dt><dd>MIT, free for any use</dd></div>
++ </dl>
++ <div class="ic-acts">
++ <a class="ic-btn is-go" href="${svgUrl}" download="${esc(name)}.svg">${kit.icon('download')} SVG</a>
++ <a class="ic-btn" href="#png">${kit.icon('image')} Make a PNG</a>
++ <button type="button" class="ic-btn" data-gp-copy="${svgUrl}" hidden>Copy the SVG</button>
++ </div>
++ </div>
++</header>
++<section class="gp-sizes" aria-label="At text sizes">${[12, 16, 20, 24, 32, 48].map((s) => `<span style="font-size:${s}px">${glyph(name, s)}<i>Aa</i><small>${s}</small></span>`).join('')}</section>`;
++
++ const maker = `<section class="ic-sec" id="png" aria-labelledby="gp-png">
++ <div class="ic-sec-head"><h2 id="gp-png">Make a PNG</h2><p>Any size up to 1024 pixels at 1×, 2× or 3×, in any colour or the mark&rsquo;s two, a lighter or heavier line, room around it and a ground behind it. The link rebuilds the same file every time.</p></div>
++ <form class="gp-maker" method="get" action="/icons/${esc(name)}#png" data-gp-form>
++ <div class="gp-preview" data-gp-preview data-ground="${esc(opts.ground)}"><img src="${esc(pngUrl)}" alt="${esc(T)} icon, ${opts.size} pixels" width="${Math.min(opts.size, 256)}" height="${Math.min(opts.size, 256)}" data-gp-img></div>
++ <div class="gp-controls">
++ ${sel('size', ex.SIZES.map((s) => [s, `${s} px`]), opts.size, 'Size')}
++ ${sel('scale', [[1, '1×'], [2, '2× (sharp screens)'], [3, '3×']], opts.scale, 'Density')}
++ <label class="gp-field"><span>Colour</span><span class="gp-colour"><input type="color" value="#${esc(opts.color === 'mark' ? '4f7cff' : opts.color)}" data-gp-colour aria-label="Line colour"><input type="text" name="color" value="${esc(opts.color)}" maxlength="6" pattern="mark|[0-9a-fA-F]{3}|[0-9a-fA-F]{6}" spellcheck="false" data-gp="color"><button type="button" class="gp-chip" data-gp-mark aria-pressed="${opts.color === 'mark'}">The mark&rsquo;s</button></span></label>
++ ${range('stroke', 1, 3, 0.25, opts.stroke, 'Line', '')}
++ ${range('pad', 0, 40, 1, opts.pad, 'Room around it', '%')}
++ ${sel('ground', [['none', 'None, transparent'], ['circle', 'A circle'], ['squircle', 'A rounded square'], ['square', 'A square']], opts.ground, 'Ground')}
++ <label class="gp-field" data-gp-bg-field${opts.ground === 'none' ? ' hidden' : ''}><span>Ground colour</span><span class="gp-colour"><input type="color" value="#${esc(opts.bg)}" data-gp-bgpick aria-label="Ground colour"><input type="text" name="bg" value="${esc(opts.bg)}" maxlength="6" pattern="[0-9a-fA-F]{3}|[0-9a-fA-F]{6}" spellcheck="false" data-gp="bg"></span></label>
++ <div class="gp-go">
++ <noscript><button type="submit" class="ic-btn">Show it</button></noscript>
++ <a class="ic-btn is-go" href="${esc(pngUrl)}" download="${esc(name)}-${opts.size * opts.scale}.png" data-gp-png>${kit.icon('download')} PNG, ${opts.size * opts.scale} px</a>
++ <a class="ic-btn" href="${esc(`/icons/${name}.svg${q}`)}" download="${esc(name)}.svg" data-gp-svg>SVG with these choices</a>
++ </div>
++ </div>
++ </form>
++</section>`;
++
++ const faTag = fa.length ? `<i class="fa-solid fa-${esc(fa[0])}"></i>` : `<svg><use href="${base}/shared/ov-glyphs.svg#ovg-${esc(name)}"/></svg>`;
++ const use = `<section class="ic-sec" aria-labelledby="gp-use">
++ <div class="ic-sec-head"><h2 id="gp-use">Use it</h2><p>Every way is the same drawing. On an OpenVibe site the navbar already loads the drop-in, so Font Awesome&rsquo;s markup shows it.</p></div>
++ <div class="gp-use">
++ ${fa.length ? `<div><h3>Font Awesome markup</h3>${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>\n${faTag}`, id: 'gp-fa', label: 'Copy' })}</div>` : ''}
++ ${cp ? `<div><h3>The font</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-font.css">\n<i class="ovi ovi-${name}"></i>`, id: 'gp-font', label: 'Copy' })}</div>` : ''}
++ <div><h3>The sprite</h3>${kit.snippet({ code: `<svg width="24" height="24"><use href="${base}/shared/ov-glyphs.svg#ovg-${name}"/></svg>`, id: 'gp-sprite', label: 'Copy' })}</div>
++ <div><h3>Node</h3>${kit.snippet({ code: `require('openvibe-shared/glyphs').svg('${name}', { size: 24 })`, id: 'gp-node', label: 'Copy' })}</div>
++ <div><h3>A link</h3>${kit.snippet({ code: `${base}/icons/${name}.svg\n${base}/icons/${name}.png?size=512&color=mark`, id: 'gp-url', label: 'Copy' })}</div>
++ </div>
++</section>`;
++
++ const near = related(name);
++ const rel = `<section class="ic-sec" aria-labelledby="gp-rel">
++ <div class="ic-sec-head"><h2 id="gp-rel">Near it</h2><p>Glyphs that share a word with ${esc(name)}, then its neighbours. <a href="/icons">All ${glyphs.NAMES.length}</a>.</p></div>
++ <div class="ic-grid" style="--ic-size:24px">${near.map((g) => `<a class="ic-tile" href="/icons/${esc(g)}">${glyph(g)}<span>${esc(g)}</span></a>`).join('')}</div>
++</section>`;
++
++ const jsonLd = [
++ {
++ '@context': 'https://schema.org', '@type': 'ImageObject', name: `${T} icon`, caption: `${T} icon from the OpenVibe glyphs`,
++ contentUrl: `${base}${svgUrl}`, thumbnailUrl: `${base}/icons/${name}.png?size=256`, encodingFormat: 'image/svg+xml',
++ license: 'https://opensource.org/license/mit', acquireLicensePage: `${base}/icons/${name}`,
++ creditText: 'OpenVibe', copyrightNotice: 'OpenVibe, MIT licence', creator: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' },
++ keywords: [name, ...fa.map((f) => `fa-${f}`), 'icon', 'svg', 'png', 'font'].join(', '),
++ },
++ { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [
++ { '@type': 'ListItem', position: 1, name: 'Icons', item: `${base}/icons` },
++ { '@type': 'ListItem', position: 2, name: T, item: `${base}/icons/${name}` },
++ ] },
++ ];
++ const head = `${T} icon: free SVG, PNG and font`;
++ return layout({
++ title: head.length <= 40 ? head : `${T} icon: SVG, PNG, font`,
++ description: `A free ${T.toLowerCase()} icon: download the SVG, make a PNG at any size and colour, or use the font.${fa.length ? ` A drop-in for Font Awesome's fa-${fa[0]}.` : ''} MIT.`.slice(0, 160),
++ path: `/icons/${name}`, active: '/icons', styles: ['css/icons.css'], scripts: ['js/icon.js'], jsonLd,
++ image: `${base}/icons/${name}.png?size=512&color=mark&pad=18&ground=squircle&bg=0b1020`, imageAlt: `The ${T.toLowerCase()} icon`,
++ body: `<article class="ic gp">${hero}${maker}${use}${rel}</article>`,
++ });
++}
++
++function faAlternativePage({ base = 'https://openvibe.design' } = {}) {
++ const covered = usage.names.filter((n) => glyphs.fromFa(n.name)).reduce((a, n) => a + n.uses, 0);
++ const total = usage.names.reduce((a, n) => a + n.uses, 0);
++ const share = Math.round((100 * covered) / Math.max(1, total));
++ const rows = [
++ ['Your markup, unchanged', '<code>&lt;i class="fa-solid fa-eye"&gt;</code> shows the glyph', 'yes'],
++ ['Sizes', '<code>fa-2xs</code> … <code>fa-2xl</code>, <code>fa-1x</code> … <code>fa-10x</code>, <code>fa-fw</code>', 'yes'],
++ ['Lists, borders, pulls', '<code>fa-ul</code>, <code>fa-li</code>, <code>fa-border</code>, <code>fa-pull-left</code>, <code>fa-pull-right</code>', 'yes'],
++ ['Turns and flips', '<code>fa-rotate-90/180/270</code>, <code>fa-rotate-by</code>, <code>fa-flip-*</code>', 'yes'],
++ ['Stacks and layers', '<code>fa-stack</code>, <code>fa-inverse</code>, <code>fa-layers</code> with counters and text', 'yes'],
++ ['Animations', '<code>fa-beat</code>, <code>fa-bounce</code>, <code>fa-fade</code>, <code>fa-beat-fade</code>, <code>fa-flip</code>, <code>fa-shake</code>, with Font Awesome&rsquo;s variables', 'yes'],
++ ['Spinners', '<code>fa-spin</code> breathes instead of spinning, and a spinner icon is three dots that take turns', 'ours'],
++ ['Weights', '<code>fa-thin</code> and <code>fa-light</code> draw thinner lines from the same drawing', 'yes'],
++ ['Power Transforms', '<code>data-fa-transform</code>: grow, shrink, up, down, left, right, rotate, flip', 'yes'],
++ ['Web font', '<code>ov-glyphs.woff2</code>, with Font Awesome&rsquo;s class names on it (<code>ov-glyphs-fa.css</code>)', 'yes'],
++ ['Desktop fonts', 'TTF and OTF, free', 'yes'],
++ ['Subset fonts', 'Coming: pick your icons, get a font with only those', 'soon'],
++ ['Brand logos', 'Not ours to draw: keep Font Awesome Brands for those', 'no'],
++ ['Price', 'Free, MIT, for everything above', 'yes'],
++ ];
++ const mark = { yes: ['ok', 'Yes'], ours: ['ours', 'Our way'], soon: ['soon', 'Soon'], no: ['no', 'Keep theirs'] };
++ const faqs = [
++ ['Is it free?', 'Yes. The glyphs, the fonts, the drop-in and the PNG maker are free for any use under the MIT licence.'],
++ ['Do I change my HTML?', 'No. Add one script tag and your Font Awesome markup shows the OpenVibe glyphs, with its classes and animations working. Or use the font with Font Awesome\'s class names on it.'],
++ ['What about icons you have not drawn?', `They stay Font Awesome's until they are drawn, so nothing goes blank. ${share}% of what OpenVibe's own pages ask Font Awesome for is covered today, and anyone signed in can ask OpenVibe.AI to draw a missing one.`],
++ ['Brand logos?', 'Logos belong to their brands: keep Font Awesome Brands (or Simple Icons) for those.'],
++ ['Does it work without JavaScript?', 'The font does: link ov-glyphs-fa.css and the font replaces Font Awesome\'s. The drop-in needs JavaScript and loads after the page has drawn.'],
++ ['Desktop apps and design tools?', 'Download the TTF or OTF and type the character, or use the SVGs.'],
++ ];
++ const body = `<article class="ic fa-alt">
++<header class="ic-hero">
++ <p class="ic-eye">A Font Awesome alternative</p>
++ <h1 class="ic-h1">Keep your markup. Change your icons.</h1>
++ <p class="ic-lede">One script tag, and every <code>fa-</code> icon on your page becomes an OpenVibe glyph: one hand, the mark&rsquo;s, in your text&rsquo;s colour and size. Font Awesome&rsquo;s classes, animations and transforms keep working. Free, MIT, with the fonts too.</p>
++ <div class="ic-acts">${kit.button({ href: '#switch', label: 'Switch in a minute', icon: 'code' })}${kit.button({ href: '/icons', label: `See all ${glyphs.NAMES.length}`, kind: 'quiet', icon: 'grid' })}</div>
++</header>
++<section class="ic-sec" id="switch" aria-labelledby="fa-switch">
++ <div class="ic-sec-head"><h2 id="fa-switch">Switch in a minute</h2><p>${Object.keys(glyphs.FA).length} Font Awesome names map to ${glyphs.NAMES.length} glyphs today. A name we have not drawn stays Font Awesome&rsquo;s, so nothing goes blank.</p></div>
++ <ol class="fa-steps">
++ <li><b>Add the drop-in.</b>${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>`, id: 'fa-step1', label: 'Copy' })}</li>
++ <li><b>Check what your pages use</b> against <a href="/icons#ic-left">what is still Font Awesome</a>. Missing one? <a href="/icons#draw">Draw it</a>.</li>
++ <li><b>Drop Font Awesome&rsquo;s stylesheet</b> once every name is covered: one less stylesheet and font on every first visit. Or use the font with Font Awesome&rsquo;s names on it:${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-fa.css">`, id: 'fa-step3', label: 'Copy' })}</li>
++ </ol>
++</section>
++<section class="ic-sec" aria-labelledby="fa-parity">
++ <div class="ic-sec-head"><h2 id="fa-parity">What keeps working</h2><p>Font Awesome&rsquo;s documented features, with its class names and CSS variables.</p></div>
++ <div class="fa-table" role="table" aria-label="Font Awesome features">${rows.map(([what, how, m]) => `<div role="row"><span role="cell" class="fa-what">${what}</span><span role="cell" class="fa-how">${how}</span><span role="cell" class="fa-mark is-${mark[m][0]}">${mark[m][1]}</span></div>`).join('')}</div>
++</section>
++<section class="ic-sec" aria-labelledby="fa-faq">
++ <div class="ic-sec-head"><h2 id="fa-faq">Questions</h2><p>Short answers.</p></div>
++ <div class="fa-faq">${faqs.map(([q, a]) => `<details><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</div>
++</section>
++</article>`;
++ return layout({
++ title: 'A free Font Awesome alternative: same markup',
++ description: `Keep your Font Awesome markup and show the OpenVibe glyphs: one script, the same classes, animations and transforms, plus free web and desktop fonts. MIT.`,
++ path: '/icons/font-awesome-alternative', active: '/icons', styles: ['css/icons.css'], scripts: [],
++ jsonLd: [{ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faqs.map(([q, a]) => ({ '@type': 'Question', name: q, acceptedAnswer: { '@type': 'Answer', text: a } })) }],
++ body,
++ });
++}
++
++module.exports = { glyphPage, faAlternativePage, related, faOf, title };
ddiff --git a/server/views/icons.js b/server/views/icons.js
iindex 1e2806d..50ce333 100644
---- a/server/views/icons.js
++++ b/server/views/icons.js
@@@ -14,6 +14,7 @@ const kit = require('openvibe-shared/kit');
const glyphs = require('openvibe-shared/glyphs');
const { layout, esc } = require('../render/layout');
const usage = require('../glyphs/fa-usage.json');
++const ex = require('../glyphs/export');
const BRANDS = new Set(['youtube', 'twitch', 'github', 'discord', 'twitter', 'x-twitter', 'facebook', 'instagram', 'tiktok', 'reddit', 'google', 'apple', 'android', 'windows', 'linux', 'steam', 'spotify', 'kickstarter', 'patreon', 'paypal', 'stripe', 'bitcoin', 'ethereum', 'telegram', 'whatsapp', 'slack', 'linkedin', 'mastodon', 'bluesky', 'vimeo', 'soundcloud', 'itch-io', 'npm', 'node-js', 'docker', 'cloudflare', 'chrome', 'firefox', 'safari', 'edge', 'opera', 'gitlab', 'bitbucket', 'figma', 'openai', 'markdown', 'kick', 'rumble', 'trello']);
@@@ -40,14 +41,14 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
const tile = (name) => {
const aliases = fa.get(name) || [];
const drawn = glyphs.META[name] && /Opus/.test(glyphs.META[name].by) ? 'Drawn for the set' : 'From the kit';
-- return `<button type="button" class="ic-tile" data-name="${esc(name)}" data-fa="${esc(aliases.join(' '))}" data-by="${esc(drawn)}" aria-label="${esc(name)}">${svg(inner(name))}<span>${esc(name)}</span></button>`;
++ return `<a class="ic-tile" href="/icons/${esc(name)}" data-name="${esc(name)}" data-fa="${esc(aliases.join(' '))}" data-by="${esc(drawn)}">${svg(inner(name))}<span>${esc(name)}</span></a>`;
};
const hero = `<header class="ic-hero">
<p class="ic-eye">The OpenVibe glyphs</p>
<h1 class="ic-h1">One hand for every icon.</h1>
<p class="ic-lede">${glyphs.NAMES.length} icons in the mark&rsquo;s own line: a 24-unit grid, strokes two units wide with round ends, in the colour of the text around them. They stand in for ${Object.keys(glyphs.FA).length} Font Awesome names, ${Math.round((100 * covered) / Math.max(1, total))}% of what the network&rsquo;s pages ask for.</p>
-- <div class="ic-acts">${kit.button({ href: '#draw', label: 'Draw an icon', icon: 'spark' })}${kit.button({ href: '#use', label: 'Use them', kind: 'quiet', icon: 'code' })}</div>
++ <div class="ic-acts">${kit.button({ href: '#get', label: 'Get them all', icon: 'download' })}${kit.button({ href: '#draw', label: 'Draw an icon', kind: 'quiet', icon: 'spark' })}${kit.button({ href: '/icons/font-awesome-alternative', label: 'Switch from Font Awesome', kind: 'quiet', icon: 'code' })}</div>
</header>`;
const browse = `<section class="ic-sec" aria-labelledby="ic-all">
@@@ -63,7 +64,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
<div class="ic-sheet-head"><h2 id="ic-sheet-h" data-ic-sheet-name></h2><button type="button" class="ovk-icon-btn" data-ic-close aria-label="Close">${kit.icon('close')}</button></div>
<div class="ic-sheet-big" data-ic-sheet-big></div>
<p class="ic-sheet-meta" data-ic-sheet-meta></p>
-- <div class="ic-sheet-acts"><button type="button" class="ic-btn" data-ic-copy>Copy the SVG</button><a class="ic-btn" data-ic-download href="#" download>Download</a></div>
++ <div class="ic-sheet-acts"><button type="button" class="ic-btn" data-ic-copy>Copy the SVG</button><a class="ic-btn" data-ic-download href="#" download>SVG</a><a class="ic-btn" data-ic-png href="#" download>PNG</a><a class="ic-btn is-go" data-ic-page href="#">Its page ${kit.icon('arrow-right')}</a></div>
</div></dialog>`;
const leftHtml = left.length ? `<section class="ic-sec" aria-labelledby="ic-left">
@@@ -86,10 +87,20 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
${reviewer ? `<h3>Waiting for review</h3>${drafts.length ? `<ul class="ic-drafts">${drafts.map((d) => draftCard(d, reviewForm(d))).join('')}</ul>` : '<p class="ic-more">Nothing waiting.</p>'}` : ''}
</section>`;
++ const packs = [
++ ['ov-glyphs-svg.zip', 'Every SVG', `${glyphs.NAMES.length} files and the licence, in a ZIP`],
++ ...['ov-glyphs.woff2', 'ov-glyphs.ttf', 'ov-glyphs.otf', 'ov-glyphs.svg', 'ov-glyphs-font.css', 'ov-glyphs-fa.css'].map((f) => [f, ex.PACK[f].label, ex.PACK[f].about]),
++ ];
++ const get = `<section class="ic-sec" id="get" aria-labelledby="ic-get">
++ <div class="ic-sec-head"><h2 id="ic-get">Get them all</h2><p>Free, MIT. The fonts carry every glyph at its own character (U+E001 up, never reused), so a document made with them keeps working. Each glyph&rsquo;s page makes a PNG at any size and colour.</p></div>
++ <ul class="ic-packs">${packs.map(([f, label, about]) => `<li><a href="/icons/pack/${esc(f)}" download><b>${esc(label)}</b><small>${esc(about)}</small><code>${esc(f)}</code></a></li>`).join('')}</ul>
++</section>`;
++
const use = `<section class="ic-sec" id="use" aria-labelledby="ic-use">
<div class="ic-sec-head"><h2 id="ic-use">Use them</h2><p>Every OpenVibe site already shows them in place of Font Awesome&rsquo;s (the navbar loads the drop-in). Anyone else can too; it is open source (MIT).</p></div>
<div class="ic-use">
-- <div><h3>In place of Font Awesome</h3>${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>\n<!-- your <i class="fa-solid fa-eye"> now shows the glyph -->`, id: 'ic-use-web', label: 'Copy the HTML' })}</div>
++ <div><h3>In place of Font Awesome</h3>${kit.snippet({ code: `<script src="${base}/shared/ov-glyphs.js" defer></script>\n<!-- your <i class="fa-solid fa-eye"> now shows the glyph -->`, id: 'ic-use-web', label: 'Copy the HTML' })}<p class="ic-more">Sizes, animations and transforms keep working: <a href="/icons/font-awesome-alternative">what keeps working</a>.</p></div>
++ <div><h3>As a font</h3>${kit.snippet({ code: `<link rel="stylesheet" href="${base}/shared/ov-glyphs-font.css">\n<i class="ovi ovi-eye"></i>`, id: 'ic-use-font', label: 'Copy the HTML' })}</div>
<div><h3>On a server</h3>${kit.snippet({ code: "const glyphs = require('openvibe-shared/glyphs');\n\nglyphs.svg('live', { size: 20 }); // a standalone SVG\nglyphs.fromFa('fa-eye'); // 'eye'", id: 'ic-use-node', label: 'Copy the code' })}</div>
<div><h3>As data</h3>${kit.snippet({ code: `GET ${base}/api/v1/glyphs\nGET ${base}/icons/<name>.svg`, id: 'ic-use-api', label: 'Copy' })}</div>
</div>
@@@ -99,7 +110,7 @@ function iconsPage({ base = 'https://openvibe.design', drafts = [], mine = [], r
title: 'The OpenVibe glyphs',
description: `${glyphs.NAMES.length} icons in one hand, the OpenVibe mark's: search them, copy or download their SVG, use them in place of Font Awesome, and draw new ones with OpenVibe.AI.`,
path: '/icons', active: '/icons', styles: ['css/icons.css'], scripts: ['js/icons.js'],
-- body: `<article class="ic">${hero}${browse}${leftHtml}${drawSec}${use}</article>`,
++ body: `<article class="ic">${hero}${browse}${get}${leftHtml}${drawSec}${use}</article>`,
});
}
ddiff --git a/test/icon-exports.test.js b/test/icon-exports.test.js
nnew file mode 100644
iindex 0000000..3599bd3
---- /dev/null
++++ b/test/icon-exports.test.js
@@@ -0,0 +1,133 @@
++'use strict';
++// Every way to get a glyph (plan L32 I5/I7, 2026-10-31): each glyph's own page (what a search for "<name> icon" lands
++// on, with the licence in its JSON-LD), the SVG and PNG with the PNG maker's choices (clamped, never failing), the
++// fonts, the sprite and a ZIP of every SVG, and the Font Awesome alternative page; all in the sitemap.
++const assert = require('node:assert/strict');
++const zlib = require('zlib');
++const { boot, check, done } = require('./helpers/boot');
++const glyphs = require('openvibe-shared/glyphs');
++const ex = require('../server/glyphs/export');
++const { related } = require('../server/views/icon-page');
++
++const pngSize = (buf) => [buf.readUInt32BE(16), buf.readUInt32BE(20)];
++const jsonLd = (html) => [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('a glyph has its own page: the drawing, its Font Awesome names, every export, the licence', async () => {
++ const res = await t.get('/icons/eye');
++ assert.equal(res.status, 200);
++ const html = res.text;
++ assert.match(html, /<title>Eye icon: free SVG, PNG and font · OpenVibe\.Design<\/title>/);
++ assert.ok(html.includes('<h1 class="gp-h1">Eye</h1>'));
++ assert.ok(html.includes('<code>fa-eye</code>'), 'the Font Awesome name it stands in for');
++ assert.ok(html.includes(`<code>U+${ex.codepointOf('eye')}</code>`), 'its character in the font');
++ assert.ok(html.includes('<link rel="canonical" href="https://openvibe.design/icons/eye">'));
++ assert.ok(/<meta property="og:image" content="https:\/\/openvibe\.design\/icons\/eye\.png\?size=512/.test(html), 'its own share picture');
++ const img = jsonLd(html).find((j) => j['@type'] === 'ImageObject');
++ assert.equal(img.license, 'https://opensource.org/license/mit');
++ assert.equal(img.acquireLicensePage, 'https://openvibe.design/icons/eye');
++ assert.equal(img.contentUrl, 'https://openvibe.design/icons/eye.svg');
++ assert.ok(jsonLd(html).some((j) => j['@type'] === 'BreadcrumbList'));
++ assert.ok(html.includes('method="get" action="/icons/eye#png"') && html.includes('name="ground"') && html.includes('name="stroke"'), 'the PNG maker works as a form');
++ assert.ok(html.includes('ovi ovi-eye') && html.includes('/shared/ov-glyphs.svg#ovg-eye') && html.includes('fa-solid fa-eye'), 'the font, the sprite and the drop-in');
++ const near = related('eye');
++ assert.ok(near.includes('eye-off') && near.length === 12 && !near.includes('eye'));
++ assert.ok(html.includes('href="/icons/eye-off"'), 'glyphs near it, linked');
++ assert.ok(html.includes('/js/icon.js'));
++ });
++
++ await check('the maker\'s choices come back in the page, and a crawler-style URL never fails', async () => {
++ const res = await t.get('/icons/live?size=512&color=mark&ground=circle&bg=101820&pad=12&stroke=2.5');
++ assert.equal(res.status, 200);
++ assert.ok(res.text.includes('src="/icons/live.png?size=512&amp;color=mark&amp;stroke=2.5&amp;pad=12&amp;ground=circle&amp;bg=101820"'));
++ assert.ok(res.text.includes('<link rel="canonical" href="https://openvibe.design/icons/live">'), 'variants share one canonical');
++ assert.equal((await t.get('/icons/live?size=nope&color=<b>')).status, 200);
++ assert.equal((await t.get('/icons/not-a-glyph')).status, 404);
++ assert.equal((await t.get('/icons/draft')).status, 404);
++ });
++
++ await check('the SVG: unchanged by default, drawn with the choices otherwise', async () => {
++ assert.equal((await t.get('/icons/eye.svg')).text, glyphs.svg('eye', { size: 24, title: 'eye' }));
++ const res = await t.get('/icons/eye.svg?size=64&color=ff0000&stroke=1.5&pad=25&ground=squircle&bg=000000');
++ assert.equal(res.status, 200);
++ assert.match(res.headers.get('content-type'), /^image\/svg\+xml/);
++ const s = res.text;
++ assert.ok(s.startsWith('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="64" height="64">'), 'a quarter of room each side doubles the box');
++ assert.ok(s.includes('<rect width="48" height="48" rx="10.8" fill="#000000"/>'), 'the squircle ground');
++ assert.ok(s.includes('<g transform="translate(12 12)" fill="none" stroke="#ff0000" stroke-width="1.5"'));
++ assert.ok(s.includes(glyphs.path('eye')));
++ const mark = (await t.get('/icons/eye.svg?color=mark')).text;
++ assert.ok(mark.includes('<linearGradient id="ovg-mark" gradientUnits="userSpaceOnUse"') && mark.includes('stroke="url(#ovg-mark)"'), "the mark's two colours");
++ });
++
++ await check('the PNG: any size at 1-3x, clamped, transparent or on a ground', async () => {
++ const res = await t.get('/icons/heart.png?size=128&scale=2&ground=circle');
++ assert.equal(res.status, 200);
++ assert.equal(res.headers.get('content-type'), 'image/png');
++ assert.equal(res.headers.get('access-control-allow-origin'), '*');
++ assert.equal(res.buffer.subarray(1, 4).toString('latin1'), 'PNG');
++ assert.deepEqual(pngSize(res.buffer), [256, 256]);
++ assert.deepEqual(pngSize((await t.get('/icons/heart.png?size=99999')).buffer), [1024, 1024], 'clamped to 1024');
++ assert.deepEqual(pngSize((await t.get('/icons/heart.png')).buffer), [256, 256], 'the default');
++ assert.equal((await t.get('/icons/nope.png')).status, 404);
++ const o = ex.options({ size: '3', color: 'javascript:', stroke: '9', pad: '-4', ground: '<x>', bg: 'zz' });
++ assert.deepEqual(o, { size: 16, scale: 1, color: '0f172a', stroke: 3, pad: 0, ground: 'none', bg: 'ffffff' });
++ });
++
++ await check('the pack: fonts, the sprite, the CSS, the code points and a ZIP of every SVG', async () => {
++ const woff = await t.get('/icons/pack/ov-glyphs.woff2');
++ assert.equal(woff.status, 200);
++ assert.equal(woff.headers.get('content-type'), 'font/woff2');
++ assert.match(woff.headers.get('content-disposition'), /attachment; filename="ov-glyphs.woff2"/);
++ assert.equal(woff.buffer.subarray(0, 4).toString('latin1'), 'wOF2');
++ assert.equal((await t.get('/icons/pack/ov-glyphs.ttf')).buffer.readUInt32BE(0), 0x00010000);
++ assert.equal((await t.get('/icons/pack/ov-glyphs.otf')).buffer.subarray(0, 4).toString('latin1'), 'OTTO');
++ assert.ok((await t.get('/icons/pack/ov-glyphs.svg')).text.includes('<symbol id="ovg-eye"'));
++ assert.ok((await t.get('/icons/pack/ov-glyphs-font.css')).text.includes('.ovi-eye::before'));
++ assert.ok((await t.get('/icons/pack/glyph-codepoints.json')).json().glyphs.eye);
++ assert.equal((await t.get('/icons/pack/../package.json')).status, 404);
++ assert.equal((await t.get('/icons/pack/server.js')).status, 404);
++ const z = await t.get('/icons/pack/ov-glyphs-svg.zip');
++ assert.equal(z.headers.get('content-type'), 'application/zip');
++ const buf = z.buffer;
++ assert.equal(buf.readUInt32LE(0), 0x04034b50, 'a ZIP');
++ const end = buf.length - 22;
++ assert.equal(buf.readUInt32LE(end), 0x06054b50);
++ assert.equal(buf.readUInt16LE(end + 10), glyphs.NAMES.length + 1, 'every glyph and the licence');
++ const nameLen = buf.readUInt16LE(26), size = buf.readUInt32LE(18);
++ const first = zlib.inflateRawSync(buf.subarray(30 + nameLen, 30 + nameLen + size)).toString();
++ assert.equal(buf.subarray(30, 30 + nameLen).toString(), `ov-glyphs/${glyphs.NAMES[0]}.svg`);
++ assert.equal(first, glyphs.svg(glyphs.NAMES[0], { size: 24 }));
++ assert.equal(ex.crc32(Buffer.from(first)), buf.readUInt32LE(14), 'its checksum');
++ });
++
++ await check('the Font Awesome alternative: how to switch, what keeps working, questions', async () => {
++ const res = await t.get('/icons/font-awesome-alternative');
++ assert.equal(res.status, 200);
++ assert.ok(res.text.includes('Keep your markup. Change your icons.'));
++ assert.ok(res.text.includes('/shared/ov-glyphs.js') && res.text.includes('/shared/ov-glyphs-fa.css'));
++ assert.ok(res.text.includes('<code>data-fa-transform</code>') && res.text.includes('Keep theirs'), 'the parity table, honest about brands');
++ assert.ok(jsonLd(res.text).some((j) => j['@type'] === 'FAQPage' && j.mainEntity.length >= 5));
++ });
++
++ await check('the icons page links every glyph to its page and offers the pack', async () => {
++ const html = (await t.get('/icons')).text;
++ assert.equal((html.match(/<a class="ic-tile" href="\/icons\/[a-z0-9-]+"/g) || []).length, glyphs.NAMES.length);
++ assert.ok(html.includes('href="/icons/pack/ov-glyphs-svg.zip"') && html.includes('href="/icons/pack/ov-glyphs.otf"'));
++ assert.ok(html.includes('href="/icons/font-awesome-alternative"'));
++ });
++
++ await check('every glyph page is in the sitemap with its pictures; the alternative is in llms.txt', async () => {
++ const map = (await t.get('/sitemap.xml')).text;
++ assert.ok(map.includes('<loc>https://openvibe.design/icons/eye</loc>'));
++ assert.ok(map.includes('<image:image><image:loc>https://openvibe.design/icons/eye.svg</image:loc></image:image>'));
++ assert.equal((map.match(/<loc>https:\/\/openvibe\.design\/icons\/[a-z0-9-]+<\/loc>/g) || []).length, glyphs.NAMES.length + 1, 'each glyph, and the alternative');
++ assert.ok((await t.get('/llms.txt')).text.includes('/icons/font-awesome-alternative'));
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });