openvibers/openvibe.design Commits

Add open font library (#194)

OP OpenVibers committed 5 days ago · 46f8cc1eb7 · 14 files, +2127 −5

* Add open font library with durable weekly refresh

* Fonts: a specimen sheet. Names drawn in their own face two to a row on a phone, category chips, styles two to a row with a counted fold, pairings drawn as pairs; CI on the security workflow that falls back

Changes

ddiff --git a/migrations/0016_font_library.sql b/migrations/0016_font_library.sql
nnew file mode 100644
iindex 0000000..eda6c20
---- /dev/null
++++ b/migrations/0016_font_library.sql
@@@ -0,0 +1,14 @@
++-- phase: expand
++-- Public font catalogue and a durable weekly refresh claim. Older releases ignore these tables.
++CREATE TABLE IF NOT EXISTS font_families (
++ id text COLLATE "C" PRIMARY KEY,
++ metadata jsonb NOT NULL,
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
++CREATE TABLE IF NOT EXISTS font_catalog_state (
++ id integer PRIMARY KEY CHECK (id = 1),
++ last_success_at timestamptz,
++ claimed_until timestamptz,
++ claim_id text
++);
++INSERT INTO font_catalog_state (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
ddiff --git a/public/css/fonts.css b/public/css/fonts.css
nnew file mode 100644
iindex 0000000..d2cc957
---- /dev/null
++++ b/public/css/fonts.css
@@@ -0,0 +1,61 @@
++/* The font library: plain catalogue pages, ready for the final Design art direction. */
++.fo{max-width:1180px;margin:auto;padding:clamp(22px,4vw,48px) clamp(16px,3vw,32px) 96px;color:var(--text-primary)}
++.fo-hero{max-width:820px;margin:0 0 32px}.fo-eyebrow{margin:0 0 12px;color:var(--accent);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}
++.fo-hero h1{margin:0;font-size:clamp(36px,6vw,72px);line-height:1.08;letter-spacing:-.035em;overflow-wrap:anywhere}.fo-hero p:last-child{color:var(--text-secondary);font-size:clamp(16px,2vw,20px);line-height:1.5}
++.fo-filters{display:grid;grid-template-columns:minmax(180px,2fr) repeat(3,minmax(120px,1fr)) auto;align-items:end;gap:12px;padding:18px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary)}
++.fo-filters label,.fo-try{display:grid;gap:7px;color:var(--text-secondary);font-size:13px;font-weight:700}.fo-filters input,.fo-filters select,.fo-try input{width:100%;min-width:0;min-height:44px;padding:9px 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-primary);color:var(--text-primary);font:inherit;font-size:15px}
++.fo-filters button{min-height:44px;padding:9px 18px;border:0;border-radius:10px;background:var(--accent);color:var(--bg-primary);font-weight:800;cursor:pointer}.fo-try{margin:22px 0}.fo-try input{font-weight:400}.fo-count{color:var(--text-secondary);font-size:14px}
++.fo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(280px,100%),1fr));gap:16px}.fo-card{min-width:0;padding:22px;border:1px solid var(--border);border-radius:18px;background:var(--bg-secondary)}
++.fo-card-top{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px;color:var(--text-secondary);font-size:12px;text-transform:capitalize}.fo-card h2{margin:22px 0 12px;font-size:22px}.fo-card h2 a,.fo-pairings a{color:inherit;text-decoration:none}.fo-card h2 a:hover,.fo-pairings a:hover{text-decoration:underline}.fo-sample{min-height:95px;margin:0;font-size:clamp(22px,2.4vw,28px);line-height:1.25;overflow-wrap:anywhere}.fo-meta{margin:18px 0 0;color:var(--text-secondary);font-size:13px}
++.fo-back{display:inline-block;margin-bottom:26px;color:var(--accent)}.fo-detail section{margin-top:44px}.fo-detail h2{font-size:clamp(22px,3vw,30px)}.fo-styles{display:grid;gap:12px}.fo-style{min-width:0;padding:16px 20px;border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary)}.fo-style>div{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;font-size:14px}.fo-style p{margin:15px 0 2px;font-size:clamp(22px,4vw,42px);line-height:1.3;overflow-wrap:anywhere}
++.fo-details{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px}.fo-details>div{min-width:0}.fo-details pre{max-width:100%;overflow-x:auto;padding:18px;border:1px solid var(--border);border-radius:12px;background:var(--bg-secondary);font-size:13px}.fo-details p{overflow-wrap:anywhere}.fo-integrity{display:block;margin-top:12px;color:var(--text-secondary);font-size:12px;overflow-wrap:anywhere}.fo-pairings{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.fo-pairings article{min-width:0;padding:18px;border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary)}.fo-pairings h3{margin:0 0 10px}.fo-pairings p{margin:0;color:var(--text-secondary);line-height:1.5}
++@media(max-width:850px){.fo-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.fo-filters button{grid-column:1/-1}.fo-details{grid-template-columns:1fr}}@media(max-width:550px){.fo-filters{grid-template-columns:1fr}.fo-pairings{grid-template-columns:1fr}}
++
++/* Design pass (Opus, 11-08): the catalogue as a specimen sheet. Each family's name is drawn in itself, two to a row on
++ a phone; categories are chips; a family's styles go two to a row with a counted fold; a pairing is drawn as a pair. */
++.fo-chips{display:flex;gap:6px;margin:0 0 12px;overflow-x:auto;scrollbar-width:none;padding:2px 0 4px}
++.fo-chips::-webkit-scrollbar{display:none}
++.fo-chips a{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:36px;padding:0 14px;border:1px solid var(--border);border-radius:999px;color:var(--text-secondary);text-decoration:none;font-size:14px;font-weight:650;text-transform:capitalize}
++.fo-chips a span{color:var(--text-muted,var(--text-secondary));font-weight:500;font-size:12px}
++.fo-chips a[aria-current]{border-color:var(--accent);color:var(--text-primary);background:color-mix(in srgb,var(--accent) 14%,transparent)}
++.fo-filters{grid-template-columns:minmax(200px,2fr) repeat(2,minmax(120px,1fr)) auto;padding:12px;gap:10px}
++.fo-try{margin:14px 0 6px}
++.fo-grid{grid-template-columns:repeat(auto-fill,minmax(min(240px,100%),1fr));gap:12px}
++.fo-card{padding:0;border-radius:16px;transition:border-color .15s,transform .15s}
++.fo-card:hover{border-color:var(--accent)}
++.fo-card:active{transform:scale(.99)}
++.fo-card-link{display:grid;gap:8px;padding:16px 16px 14px;color:inherit;text-decoration:none;height:100%;box-sizing:border-box}
++.fo-card h2{margin:0;font-size:28px;line-height:1.1;font-weight:400;overflow-wrap:anywhere}
++.fo-card .fo-sample{min-height:0;font-size:17px;line-height:1.35;color:var(--text-secondary)}
++.fo-meta{margin:auto 0 0;font-size:12px;color:var(--text-muted,var(--text-secondary))}
++.fo-meta span{text-transform:capitalize}
++.fo-n{margin-left:6px;font-size:.6em;font-weight:600;color:var(--text-muted,var(--text-secondary))}
++.fo-styles{grid-template-columns:repeat(auto-fill,minmax(min(420px,100%),1fr))}
++.fo-style{padding:12px 16px}
++.fo-style p{margin:6px 0 0;font-size:22px;line-height:1.25;overflow-wrap:anywhere}
++.fo-style>div{font-size:13px;color:var(--text-secondary)}
++.fo-style a{color:var(--accent);font-weight:650;text-decoration:none}
++.fo-more{margin-top:12px}
++.fo-more>summary{cursor:pointer;display:inline-flex;align-items:center;gap:6px;min-height:40px;padding:0 16px;border:1px solid var(--border);border-radius:999px;font-weight:650;list-style:none}
++.fo-more>summary::-webkit-details-marker{display:none}
++.fo-more>summary span{color:var(--text-secondary);font-weight:500}
++.fo-more[open]>summary{margin-bottom:12px}
++.fo-note{color:var(--text-secondary);max-width:70ch}
++.fo-pairings{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));gap:12px}
++.fo-pairings article{padding:18px;border:1px solid var(--border);border-radius:16px;background:var(--bg-secondary)}
++.fo-pair-h{margin:0;font-size:26px;line-height:1.15}
++.fo-pair-b{margin:8px 0 12px;font-size:16px;line-height:1.5;color:var(--text-secondary)}
++.fo-pair-why{margin:0;font-size:12.5px;color:var(--text-muted,var(--text-secondary))}
++@media(max-width:550px){
++ .fo-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
++ .fo-card-link{padding:12px}
++ .fo-card h2{font-size:22px}
++ .fo-card .fo-sample{font-size:14px}
++ .fo-filters{grid-template-columns:1fr 1fr}
++ .fo-filters .fo-q,.fo-filters button{grid-column:1/-1}
++ .fo-style p{font-size:19px}
++}
++/* The selects look like the search field, not like buttons (a theme's button colours reached them on a phone) */
++.fo .fo-filters select{appearance:none;-webkit-appearance:none;background:var(--bg-primary) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%2396a7c2' stroke-width='2'/%3E%3C/svg%3E") no-repeat right 12px center;color:var(--text-primary);border:1px solid var(--border);padding-right:32px;box-shadow:none}
++.fo .fo-filters button{background:transparent;color:var(--text-primary);border:1px solid var(--accent)}
++.fo .fo-filters button:hover{background:color-mix(in srgb,var(--accent) 14%,transparent)}
ddiff --git a/public/js/fonts.js b/public/js/fonts.js
nnew file mode 100644
iindex 0000000..1334608
---- /dev/null
++++ b/public/js/fonts.js
@@@ -0,0 +1,8 @@
++'use strict';
++document.addEventListener('DOMContentLoaded', () => {
++ const input = document.querySelector('[data-font-sample]');
++ if (!input) return;
++ const samples = [...document.querySelectorAll('.fo-sample,.fo-style p,.fo-pair-b')];
++ const original = samples.map(node => node.textContent);
++ input.addEventListener('input', () => samples.forEach((node, index) => { node.textContent = input.value || original[index]; }));
++});
ddiff --git a/server/app.js b/server/app.js
iindex 1de1844..edce41a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -45,6 +45,8 @@ const { createPageRoutes } = require('./http/pages');
const { createTokenFileRoutes } = require('./http/tokens');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createIconRoutes } = require('./http/icons');
++const { createFontRoutes } = require('./http/fonts');
++const { createFontCatalog } = require('./fonts/catalog');
const { createServiceReadiness } = require('./observability');
const { createArenaStore } = require('./arena/store');
const { createArenaAi } = require('./arena/ai');
@@@ -97,12 +99,14 @@ async function createApp(opts = {}) {
const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });
const accountData = accountDataLib.create({ db: s.db, log });
const picks = opts.picks || require('./brand-picks').createBrandPicks({ db: s.db, now });
++ const fonts = opts.fonts || createFontCatalog({ db: s.db, fetchImpl, now, log });
++ await fonts.seedMissing();
const accountSend = opts.accountSend || (config.oauth.clientSecret
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
-- config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks,
++ config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts,
random: opts.random || Math.random,
};
@@@ -136,7 +140,7 @@ async function createApp(opts = {}) {
// The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', ...turnstileSrc],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
-- fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com', 'https://cdn.jsdelivr.net'],
// Theme previews may carry inline data: images and any https image the catalogue references.
imgSrc: ["'self'", 'data:', 'https:'],
// The footer's release line hears OpenVibe.Events; Cloudflare's beacon reports page timings.
@@@ -275,6 +279,7 @@ async function createApp(opts = {}) {
app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));
app.get('/api/v1/prompts', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); res.json({ prompts: require('./views/prompts').list() }); });
app.use(createIconRoutes(ctx));
++ app.use(createFontRoutes(ctx));
app.use('/api', createApi(ctx));
// GET /openapi.json (SEO-3): the API as OpenAPI 3.1. It is not in openvibe-contracts, so the document is read from
// the routes this app mounts under /api/, with the summaries the API files' header comments give them
@@@ -285,7 +290,7 @@ async function createApp(opts = {}) {
app.get('/openapi.json', require('./http/openapi-routes').openapiRoute(app, {
title: `${d.name} API`, description: d.purpose, version: require('../package.json').version, baseUrl: site,
docs: `${site}/developers`,
-- files: ['http/api.js', 'http/arena-api.js', 'http/brand-api.js', 'http/design-api.js'].map((f) => require('path').join(__dirname, f)),
++ files: ['http/api.js', 'http/arena-api.js', 'http/brand-api.js', 'http/design-api.js', 'http/fonts.js'].map((f) => require('path').join(__dirname, f)),
}));
}
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', {
ddiff --git a/server/fonts/catalog.js b/server/fonts/catalog.js
nnew file mode 100644
iindex 0000000..b273c0c
---- /dev/null
++++ b/server/fonts/catalog.js
@@@ -0,0 +1,154 @@
++'use strict';
++
++const crypto = require('node:crypto');
++const seed = require('./seed.json');
++
++const WEEK = 7 * 24 * 60 * 60 * 1000;
++const CLAIM = 10 * 60 * 1000;
++const ALLOWED = new Set(['OFL-1.1', 'Apache-2.0', 'UFL-1.0']);
++const CDN = /^https:\/\/cdn\.jsdelivr\.net\/fontsource\/fonts\/[a-z0-9-]+@[0-9][a-z0-9.-]*\/[a-z0-9-]+\.woff2$/;
++const IDS = seed.map(f => f.id);
++
++function validFont(f) {
++ return f && /^[a-z0-9-]+$/.test(f.id) && typeof f.family === 'string' && /^[A-Za-z0-9 .'-]{1,100}$/.test(f.family)
++ && typeof f.designer === 'string' && f.designer.length > 0
++ && ALLOWED.has(f.license) && /^https:\/\/github\.com\/google\/fonts\/tree\/main\/(ofl|apache|ufl)\/[a-z0-9]+$/.test(f.license_url)
++ && /^https:\/\//.test(f.source) && typeof f.version === 'string'
++ && Array.isArray(f.weights) && f.weights.every(w => Number.isInteger(w) && w >= 100 && w <= 900)
++ && Array.isArray(f.styles) && f.styles.every(s => ['normal', 'italic'].includes(s))
++ && Array.isArray(f.subsets) && f.subsets.every(s => /^[a-z0-9-]+$/.test(s))
++ && f.files && Object.keys(f.files).length > 0 && Object.values(f.files).every(url => CDN.test(url));
++}
++
++function due(state, now) {
++ if (state.claimed_until && new Date(state.claimed_until).getTime() > now) return false;
++ return !state.last_success_at || now - new Date(state.last_success_at).getTime() >= WEEK;
++}
++
++function profile(f) {
++ const classic = ['serif', 'display', 'handwriting'].includes(f.category) || ['lato', 'oswald', 'josefin-sans', 'ubuntu'].includes(f.id);
++ const short = ['serif', 'display', 'handwriting'].includes(f.category) || ['raleway', 'oswald', 'abril-fatface'].includes(f.id);
++ return { era: classic ? 'classic' : 'modern', xHeight: short ? 'low' : 'high' };
++}
++
++function pairings(f, all) {
++ const a = profile(f);
++ const candidates = all.filter(g => g.id !== f.id).map(g => {
++ const b = profile(g);
++ const category = f.category !== g.category;
++ const height = a.xHeight !== b.xHeight;
++ const era = a.era !== b.era;
++ const headWeight = g.files['700-normal'] ? 700 : g.files['600-normal'] ? 600 : g.files['400-normal'] ? 400 : Number(String(Object.keys(g.files)[0] || '400').split('-')[0]);
++ return { family: g.family, id: g.id, category: g.category, file: g.files[`${headWeight}-normal`] || Object.values(g.files)[0], weight: headWeight, score: (category ? 4 : 0) + (height ? 2 : 0) + (era ? 1 : 0),
++ reason: [category && `contrasting ${g.category} and ${f.category} forms`, height && 'different x-height', era && 'classic and modern character'].filter(Boolean).join('; ') || 'a quieter companion' };
++ }).sort((x, y) => y.score - x.score || x.family.localeCompare(y.family));
++ const chosen = [];
++ for (const candidate of candidates) if (!chosen.some(p => p.category === candidate.category)) {
++ chosen.push(candidate);
++ if (chosen.length === 3) break;
++ }
++ for (const candidate of candidates) if (chosen.length < 3 && !chosen.includes(candidate)) chosen.push(candidate);
++ return chosen.map(({ score, category, ...p }) => p);
++}
++
++function createFontCatalog({ db, fetchImpl = fetch, now = () => Date.now(), log = console } = {}) {
++ let timer = null;
++ let running = false;
++ async function seedMissing() {
++ // A completed refresh may have removed a family whose licence changed. Never restore it on restart.
++ const state = await db.maybe('SELECT last_success_at FROM font_catalog_state WHERE id = 1');
++ if (state?.last_success_at) return;
++ for (const f of seed) {
++ if (!validFont(f)) throw new Error(`Invalid font seed: ${f.id}`);
++ await db.exec('INSERT INTO font_families (id, metadata) VALUES ($1, $2::jsonb) ON CONFLICT (id) DO NOTHING', [f.id, JSON.stringify(f)]);
++ }
++ }
++ async function list(filters = {}) {
++ const rows = await db.many('SELECT metadata FROM font_families ORDER BY id');
++ const q = String(filters.q || '').trim().toLowerCase().slice(0, 100);
++ const weight = filters.weight ? Number(filters.weight) : null;
++ const fonts = rows.map(r => r.metadata).filter(validFont).filter(f =>
++ (!q || `${f.family} ${f.designer}`.toLowerCase().includes(q))
++ && (!filters.category || f.category === filters.category)
++ && (!filters.script || f.subsets.includes(filters.script))
++ && (!weight || f.weights.includes(weight)));
++ return fonts;
++ }
++ async function get(id) {
++ if (!/^[a-z0-9-]{1,100}$/.test(id)) return null;
++ const row = await db.maybe('SELECT metadata FROM font_families WHERE id = $1', [id]);
++ if (!row || !validFont(row.metadata)) return null;
++ return { ...row.metadata, pairings: pairings(row.metadata, await list()) };
++ }
++ async function fetchFont(old) {
++ const response = await fetchImpl(`https://api.fontsource.org/v1/fonts/${old.id}`, { signal: AbortSignal.timeout(15_000) });
++ if (!response.ok) throw new Error(`Fontsource ${old.id}: HTTP ${response.status}`);
++ const f = await response.json();
++ if (!ALLOWED.has(f.license)) return null;
++ const dir = f.license === 'Apache-2.0' ? 'apache' : f.license === 'UFL-1.0' ? 'ufl' : 'ofl';
++ const licenseUrl = `https://github.com/google/fonts/tree/main/${dir}/${old.id.replace(/-/g, '')}`;
++ const metaUrl = `${licenseUrl.replace('https://github.com/google/fonts/tree/main/', 'https://raw.githubusercontent.com/google/fonts/main/')}/METADATA.pb`;
++ const metadata = await fetchImpl(metaUrl, { signal: AbortSignal.timeout(15_000) });
++ if (!metadata.ok) throw new Error(`Font metadata ${old.id}: HTTP ${metadata.status}`);
++ const designer = (String(await metadata.text()).match(/^designer: "([^"]+)"/m) || [])[1];
++ const files = {};
++ for (const weight of f.weights || []) for (const style of f.styles || []) {
++ const url = f.variants?.[weight]?.[style]?.[f.defSubset]?.url?.woff2;
++ if (url) files[`${weight}-${style}`] = url;
++ }
++ const result = { id: f.id, family: f.family, designer, category: f.category,
++ weights: f.weights, styles: f.styles, subsets: f.subsets, license: f.license,
++ version: f.version, source: f.source, license_url: licenseUrl, files };
++ if (!validFont(result)) throw new Error(`Invalid metadata for ${old.id}`);
++ const preview = result.files['400-normal'] || Object.values(result.files)[0];
++ const asset = await fetchImpl(preview, { signal: AbortSignal.timeout(15_000) });
++ if (asset.ok) result.sri = `sha384-${crypto.createHash('sha384').update(Buffer.from(await asset.arrayBuffer())).digest('base64')}`;
++ return result;
++ }
++ async function tick() {
++ if (running) return false;
++ running = true;
++ const time = now();
++ const claimId = crypto.randomUUID();
++ try {
++ const claim = await db.maybe(`UPDATE font_catalog_state SET claim_id = $1, claimed_until = $2::timestamptz
++ WHERE id = 1 AND (claimed_until IS NULL OR claimed_until <= $3::timestamptz)
++ AND (last_success_at IS NULL OR last_success_at <= $4::timestamptz) RETURNING id`,
++ [claimId, new Date(time + CLAIM).toISOString(), new Date(time).toISOString(), new Date(time - WEEK).toISOString()]);
++ if (!claim) return false;
++ try {
++ const results = [];
++ // Keep the remote request rate modest; the claim spans the whole refresh.
++ for (const id of IDS) {
++ const old = seed.find(f => f.id === id);
++ const result = await fetchFont(old);
++ if (result) results.push(result);
++ }
++ await db.tx(async () => {
++ const held = await db.maybe('SELECT id FROM font_catalog_state WHERE id = 1 AND claim_id = $1 FOR UPDATE', [claimId]);
++ if (!held) return;
++ for (const f of results) await db.exec(`INSERT INTO font_families (id, metadata, updated_at) VALUES ($1, $2::jsonb, $3::timestamptz)
++ ON CONFLICT (id) DO UPDATE SET metadata = EXCLUDED.metadata, updated_at = EXCLUDED.updated_at`,
++ [f.id, JSON.stringify(f), new Date(time).toISOString()]);
++ await db.exec('DELETE FROM font_families WHERE id <> ALL($1::text[])', [results.map(f => f.id)]);
++ await db.exec('UPDATE font_catalog_state SET last_success_at = $1::timestamptz, claimed_until = NULL, claim_id = NULL WHERE id = 1 AND claim_id = $2', [new Date(time).toISOString(), claimId]);
++ });
++ return true;
++ } catch (err) {
++ await db.exec('UPDATE font_catalog_state SET claimed_until = NULL, claim_id = NULL WHERE id = 1 AND claim_id = $1', [claimId]);
++ throw err;
++ }
++ } finally { running = false; }
++ }
++ function start() {
++ if (timer) return;
++ const run = () => tick().catch(e => log.warn('[OpenVibe.Design] font refresh:', e.message));
++ run();
++ timer = setInterval(run, 60 * 60 * 1000);
++ timer.unref?.();
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++ return { seedMissing, list, get, tick, start, stop };
++}
++
++module.exports = { createFontCatalog, validFont, due, pairings, WEEK };
ddiff --git a/server/fonts/seed.json b/server/fonts/seed.json
nnew file mode 100644
iindex 0000000..5f70ea7
---- /dev/null
++++ b/server/fonts/seed.json
@@@ -0,0 +1,1678 @@
++[
++ {
++ "id": "abril-fatface",
++ "family": "Abril Fatface",
++ "designer": "TypeTogether",
++ "category": "display",
++ "weights": [
++ 400
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "latin",
++ "latin-ext"
++ ],
++ "license": "OFL-1.1",
++ "version": "v25",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/abrilfatface",
++ "files": {
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2"
++ },
++ "sri": "sha384-wpDjVWX1raklpoCthpT+SqPotxqGYsT8rnl9vOxxtp35U2RCo75d5JEHgDqOl7YV"
++ },
++ {
++ "id": "cormorant-garamond",
++ "family": "Cormorant Garamond",
++ "designer": "Christian Thalmann",
++ "category": "serif",
++ "weights": [
++ 300,
++ 400,
++ 500,
++ 600,
++ 700
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v21",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/cormorantgaramond",
++ "files": {
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
++ },
++ "sri": "sha384-C7LOiVF8vrW1L9HuZmyWfOOwJ/AklDdrU0e8qUh/vUCDauoEUDgdnz84hC8u5UKR"
++ },
++ {
++ "id": "dancing-script",
++ "family": "Dancing Script",
++ "designer": "Impallari Type",
++ "category": "handwriting",
++ "weights": [
++ 400,
++ 500,
++ 600,
++ 700
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v29",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/dancingscript",
++ "files": {
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
++ },
++ "sri": "sha384-lrSz6x6b6m90dJcsoNLhQhuKJXyrVxpHZVtubEuWgw2Pdg1znt+/jxv9YGhfvjJ0"
++ },
++ {
++ "id": "dm-sans",
++ "family": "DM Sans",
++ "designer": "Colophon Foundry",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "latin",
++ "latin-ext"
++ ],
++ "license": "OFL-1.1",
++ "version": "v17",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/dmsans",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-ZdBBucdtQA81cDAiMrRzG0sKtablpH7avbTQ8sL/bLTJaRLqKw3iRRIk7BlZXmBL"
++ },
++ {
++ "id": "fira-code",
++ "family": "Fira Code",
++ "designer": "The Mozilla Foundation, Telefonica S.A., Nikita Prokopov",
++ "category": "monospace",
++ "weights": [
++ 300,
++ 400,
++ 500,
++ 600,
++ 700
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "greek",
++ "greek-ext",
++ "latin",
++ "latin-ext",
++ "symbols2"
++ ],
++ "license": "OFL-1.1",
++ "version": "v27",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/firacode",
++ "files": {
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
++ },
++ "sri": "sha384-8yWSvZzF1qfjfXS1YbHQUZEefwH+Xr7wHeqdaG0dWfVTJyqZrzfkxNN1K9Sn1WFG"
++ },
++ {
++ "id": "fira-sans",
++ "family": "Fira Sans",
++ "designer": "Carrois Apostrophe",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "greek",
++ "greek-ext",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v18",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/firasans",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-+mFWbPBWAziTjtBaXJHKLKt8ciu/OCjv0taTqX0caIJ0siDoWne4W6miuaELAXvj"
++ },
++ {
++ "id": "ibm-plex-mono",
++ "family": "IBM Plex Mono",
++ "designer": "Mike Abbink, Bold Monday",
++ "category": "monospace",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v20",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/ibmplexmono",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
++ },
++ "sri": "sha384-oFQAuS7n3CFHgvwGpuoFV1kPR39MQLTlQIL+UnoEvMkkFZsrFNOZy/f7x+DofQNH"
++ },
++ {
++ "id": "inconsolata",
++ "family": "Inconsolata",
++ "designer": "Raph Levien",
++ "category": "monospace",
++ "weights": [
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v37",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/inconsolata",
++ "files": {
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-SadIt3drVYfBAGMttu7SYdoD0icQVq35V3MfX8oSCrAJCwrQtE19i4TDCYb9ZTHZ"
++ },
++ {
++ "id": "inter",
++ "family": "Inter",
++ "designer": "Rasmus Andersson",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "greek",
++ "greek-ext",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v20",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/inter",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-tqh0FAOjwwo8QcrfmptsbU/OH9394dupGj2d2zJQ7lFJdE5ouzRr13+2JlwV7vrE"
++ },
++ {
++ "id": "jetbrains-mono",
++ "family": "JetBrains Mono",
++ "designer": "JetBrains, Philipp Nurullin, Konstantin Bulenkov",
++ "category": "monospace",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "greek",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v24",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/jetbrainsmono",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2"
++ },
++ "sri": "sha384-/4dCc3INKaFZBsZMku6bd44i8Gr1uXW1GYvdcNjsbHXHh2nGnk9G4RAhHf8hx3GT"
++ },
++ {
++ "id": "josefin-sans",
++ "family": "Josefin Sans",
++ "designer": "Santiago Orozco",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v34",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/josefinsans",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
++ },
++ "sri": "sha384-dnO5dwLeQGRECJGM/PT1eRqKbkQTCefJr96a9obMYjlDnlpYSmCqZYnA3n2LxpXU"
++ },
++ {
++ "id": "lato",
++ "family": "Lato",
++ "designer": "Łukasz Dziedzic",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 300,
++ 400,
++ 700,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "latin",
++ "latin-ext"
++ ],
++ "license": "OFL-1.1",
++ "version": "v25",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/lato",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-+chn3xqCWRPSXDBtVEFQn+0J9Gb0SYR2FO1WrXo+L/arnyr8lXLVX2p0BqZIUemc"
++ },
++ {
++ "id": "libre-baskerville",
++ "family": "Libre Baskerville",
++ "designer": "Impallari Type",
++ "category": "serif",
++ "weights": [
++ 400,
++ 500,
++ 600,
++ 700
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "latin",
++ "latin-ext"
++ ],
++ "license": "OFL-1.1",
++ "version": "v24",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/librebaskerville",
++ "files": {
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
++ },
++ "sri": "sha384-uRckE2OjWTlzltjCXxrZffS/bXnLA6oEECyxYUb3eix/O1iaEoOdEnkaHoB2R36J"
++ },
++ {
++ "id": "lora",
++ "family": "Lora",
++ "designer": "Cyreal",
++ "category": "serif",
++ "weights": [
++ 400,
++ 500,
++ 600,
++ 700
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "latin",
++ "latin-ext",
++ "math",
++ "symbols",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v37",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/lora",
++ "files": {
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
++ },
++ "sri": "sha384-4K0WjzceC/SOy/usx6omCD3VyEOFfhpO97BismDBHNTJVOkoaAqmoEOWkiDGJr/8"
++ },
++ {
++ "id": "manrope",
++ "family": "Manrope",
++ "designer": "Mikhail Sharanda",
++ "category": "sans-serif",
++ "weights": [
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "greek",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v20",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/manrope",
++ "files": {
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2"
++ },
++ "sri": "sha384-MZMzLPF8CUlQGCqf+feQOIKEzmoRP/SDPngpfNIiv7DasqbtsikGsheLZWB98pAR"
++ },
++ {
++ "id": "merriweather",
++ "family": "Merriweather",
++ "designer": "Sorkin Type",
++ "category": "serif",
++ "weights": [
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v33",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/merriweather",
++ "files": {
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-ZgZJGQ6rET5qzeALEh9evIZFPZkUYTzCcGEuYSyWnqSwzXc9Qyefyu+AJ+ynNhyi"
++ },
++ {
++ "id": "montserrat",
++ "family": "Montserrat",
++ "designer": "Julieta Ulanovsky, Sol Matas, Juan Pablo del Peral, Jacques Le Bailly",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v31",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/montserrat",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-TZp3MNli7D4I9HF7ut1KGl19b69Ah6oafBq+SgFy8VzLt3vFx74DrLJ2Jt/z5gUY"
++ },
++ {
++ "id": "noto-sans-arabic",
++ "family": "Noto Sans Arabic",
++ "designer": "Google",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "arabic",
++ "latin",
++ "latin-ext",
++ "math",
++ "symbols"
++ ],
++ "license": "OFL-1.1",
++ "version": "v33",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/notosansarabic",
++ "files": {
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-ktrmBjgMzmznHZm9VmzUAl/2ht+E/QB11F4mctEPuHTmK7VU8KeItUlJ945Qv6FI"
++ },
++ {
++ "id": "noto-sans-devanagari",
++ "family": "Noto Sans Devanagari",
++ "designer": "Google",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "devanagari",
++ "latin",
++ "latin-ext"
++ ],
++ "license": "OFL-1.1",
++ "version": "v30",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/notosansdevanagari",
++ "files": {
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-6qvgeq5MT8DL150wuVD7QhgdnpuMOQcit0+3WA+BihqdLH9QNWAIJVwOkPLByDHK"
++ },
++ {
++ "id": "noto-sans-jp",
++ "family": "Noto Sans JP",
++ "designer": "Google",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "japanese",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v57",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/notosansjp",
++ "files": {
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-ZMGxbGF40IH+TlOdWEUxyz1qJOy2eWeNBhWXMgE3ytakMsKeDuj4RoAPPwuk8THu"
++ },
++ {
++ "id": "noto-sans-kr",
++ "family": "Noto Sans KR",
++ "designer": "Google",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "korean",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v40",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/notosanskr",
++ "files": {
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-MQrIcZ3XdaBQuSgSMpZ5h4v6YE0xA6sy9r3IKqiQsb3b6tD6npc3FosmBEm/DHbo"
++ },
++ {
++ "id": "noto-serif",
++ "family": "Noto Serif",
++ "designer": "Google",
++ "category": "serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "greek",
++ "greek-ext",
++ "latin",
++ "latin-ext",
++ "math",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v33",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/notoserif",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-mErvIjhu9VEDQVGNFT+/Ax8p/AQ8jA03O3yWHCu9Gbkds3UzFuAaOPJgshiPzsOZ"
++ },
++ {
++ "id": "nunito",
++ "family": "Nunito",
++ "designer": "Vernon Adams, Cyreal, Jacques Le Bailly",
++ "category": "sans-serif",
++ "weights": [
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v32",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/nunito",
++ "files": {
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-/Q+nUswFTZOi6aXONHIluy9SKfcuGoXESMYZkW//IgytrlO29H5JEwZ7KyeFzHJf"
++ },
++ {
++ "id": "open-sans",
++ "family": "Open Sans",
++ "designer": "Steve Matteson",
++ "category": "sans-serif",
++ "weights": [
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "greek",
++ "greek-ext",
++ "hebrew",
++ "latin",
++ "latin-ext",
++ "math",
++ "symbols",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v44",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/opensans",
++ "files": {
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2"
++ },
++ "sri": "sha384-y5c6efoQ3OOtWXIDzjW2R3FJVtHOFICtnJqM5+8NHiPlG95BTMDNxiS862cIaIyx"
++ },
++ {
++ "id": "oswald",
++ "family": "Oswald",
++ "designer": "Vernon Adams, Kalapi Gajjar, Cyreal",
++ "category": "sans-serif",
++ "weights": [
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v57",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/oswald",
++ "files": {
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
++ },
++ "sri": "sha384-pAZFASbk49K48F9Ak4QWKRMPCS+PUVOTyZ8lbOmavPcxXfSm2MXJz7RYx/sj79m4"
++ },
++ {
++ "id": "outfit",
++ "family": "Outfit",
++ "designer": "Smartsheet Inc, Rodrigo Fuenzalida",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "latin",
++ "latin-ext"
++ ],
++ "license": "OFL-1.1",
++ "version": "v15",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/outfit",
++ "files": {
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-LAIKRxjNJSUYh5JU9qx1to4Cg5fiTZmEHpe+giOnsw5m/9nofEsQw+b65hqvaQio"
++ },
++ {
++ "id": "permanent-marker",
++ "family": "Permanent Marker",
++ "designer": "Font Diner",
++ "category": "handwriting",
++ "weights": [
++ 400
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "latin"
++ ],
++ "license": "Apache-2.0",
++ "version": "v16",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/apache/permanentmarker",
++ "files": {
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2"
++ },
++ "sri": "sha384-vT5xTr25YOIF7ASHObdDkQnLzyunaZ1r9FRHtYCen88HCgzujW8tWPo6rzU2vt77"
++ },
++ {
++ "id": "playfair-display",
++ "family": "Playfair Display",
++ "designer": "Claus Eggers Sørensen",
++ "category": "serif",
++ "weights": [
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v40",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/playfairdisplay",
++ "files": {
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-eIw2aTe54daHavGiBUeDMN9SiosN3q5uI4UASs2J59iv0Rje8ZTBs6dq/jw8WKEI"
++ },
++ {
++ "id": "poppins",
++ "family": "Poppins",
++ "designer": "Indian Type Foundry, Jonny Pinhorn, Ninad Kale",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "devanagari",
++ "latin",
++ "latin-ext"
++ ],
++ "license": "OFL-1.1",
++ "version": "v24",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/poppins",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-W4IwjlabTeW68fblL1nwDvUYgUVRfBlSg7TwKfQGYAuYTkO+zEQFxqxcc6hCAyp4"
++ },
++ {
++ "id": "raleway",
++ "family": "Raleway",
++ "designer": "Matt McInerney, Pablo Impallari, Rodrigo Fuenzalida",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v37",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/raleway",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-3TJ5i8gimHsS/euAR5EX6GeSQlDDV1iYtoHAzCtoza0M5y4Jy7FELGLUQiFFbgbd"
++ },
++ {
++ "id": "roboto",
++ "family": "Roboto",
++ "designer": "Christian Robertson, ParaType, Font Bureau",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "greek",
++ "greek-ext",
++ "latin",
++ "latin-ext",
++ "math",
++ "symbols",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v51",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/roboto",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-cL8xRHWIL6DOapsGZvH0PmgY8FHJfx1/oZFwZsJdZtGrbgWNFiUSs6P2RQldUJuZ"
++ },
++ {
++ "id": "roboto-slab",
++ "family": "Roboto Slab",
++ "designer": "Christian Robertson",
++ "category": "serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "greek",
++ "greek-ext",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "Apache-2.0",
++ "version": "v36",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/apache/robotoslab",
++ "files": {
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-8jbBGEUvybflDyOzrH9bExjVKMnrgI5Pno4lP5PVxiesK+IBAmy3bUnJqitpZlBu"
++ },
++ {
++ "id": "rubik",
++ "family": "Rubik",
++ "designer": "Hubert and Fischer, Meir Sadan, Cyreal, Daniel Grumer, Omaima Dajani",
++ "category": "sans-serif",
++ "weights": [
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "arabic",
++ "cyrillic",
++ "cyrillic-ext",
++ "hebrew",
++ "latin",
++ "latin-ext"
++ ],
++ "license": "OFL-1.1",
++ "version": "v31",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/rubik",
++ "files": {
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-h6cxownIAO3b8G3yOMfioFISos4smxsXsfM2SuVeBOt+EWzON5L8IrtaLvpmwcvk"
++ },
++ {
++ "id": "source-sans-3",
++ "family": "Source Sans 3",
++ "designer": "Paul D. Hunt",
++ "category": "sans-serif",
++ "weights": [
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "greek",
++ "greek-ext",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v20",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/sourcesans3",
++ "files": {
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-YbPa4/38CWQVCsfXlRcOJywm27n3RERUyzIuomA7HNoa7hcXwEy5uL9nHMyXj8Hb"
++ },
++ {
++ "id": "source-serif-4",
++ "family": "Source Serif 4",
++ "designer": "Frank Grießhammer",
++ "category": "serif",
++ "weights": [
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "greek",
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v15",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/sourceserif4",
++ "files": {
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-QH8kQ1dp35FXXdgaYtl6lRIMtjT62fMRls72L4GH5Apv6brOezk4QZWWj/Sam95h"
++ },
++ {
++ "id": "space-grotesk",
++ "family": "Space Grotesk",
++ "designer": "Florian Karsten",
++ "category": "sans-serif",
++ "weights": [
++ 300,
++ 400,
++ 500,
++ 600,
++ 700
++ ],
++ "styles": [
++ "normal"
++ ],
++ "subsets": [
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v22",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/spacegrotesk",
++ "files": {
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
++ },
++ "sri": "sha384-LN9HSpXtfJY+J/HQtn1ux4LTp3GzWE5bXPvrP3x2ydU9Fj4Ie3RJ8RUqZX8c834d"
++ },
++ {
++ "id": "ubuntu",
++ "family": "Ubuntu",
++ "designer": "Dalton Maag",
++ "category": "sans-serif",
++ "weights": [
++ 300,
++ 400,
++ 500,
++ 700
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "cyrillic",
++ "cyrillic-ext",
++ "greek",
++ "greek-ext",
++ "latin",
++ "latin-ext"
++ ],
++ "license": "UFL-1.0",
++ "version": "v21",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ufl/ubuntu",
++ "files": {
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2"
++ },
++ "sri": "sha384-jGsKWVoTkCuJGW0HOJgSCbQQtkTaHgnKbzKERY4HBWzy6WWCGvTYfvkugA578CAT"
++ },
++ {
++ "id": "work-sans",
++ "family": "Work Sans",
++ "designer": "Wei Huang",
++ "category": "sans-serif",
++ "weights": [
++ 100,
++ 200,
++ 300,
++ 400,
++ 500,
++ 600,
++ 700,
++ 800,
++ 900
++ ],
++ "styles": [
++ "italic",
++ "normal"
++ ],
++ "subsets": [
++ "latin",
++ "latin-ext",
++ "vietnamese"
++ ],
++ "license": "OFL-1.1",
++ "version": "v24",
++ "source": "https://github.com/google/fonts",
++ "license_url": "https://github.com/google/fonts/tree/main/ofl/worksans",
++ "files": {
++ "100-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-italic.woff2",
++ "100-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-100-normal.woff2",
++ "200-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-italic.woff2",
++ "200-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-200-normal.woff2",
++ "300-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-italic.woff2",
++ "300-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-300-normal.woff2",
++ "400-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-italic.woff2",
++ "400-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-400-normal.woff2",
++ "500-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-italic.woff2",
++ "500-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-500-normal.woff2",
++ "600-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-italic.woff2",
++ "600-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-600-normal.woff2",
++ "700-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-italic.woff2",
++ "700-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-700-normal.woff2",
++ "800-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-italic.woff2",
++ "800-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-800-normal.woff2",
++ "900-italic": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-italic.woff2",
++ "900-normal": "https://cdn.jsdelivr.net/fontsource/fonts/[email protected]/latin-900-normal.woff2"
++ },
++ "sri": "sha384-x6cgTi/NG/0nnQyCVkB8w/JUcS7wSz8cG5AEGjk2qktSQfVtBz259hNqnzRKhRnV"
++ }
++]
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 834df9e..123af77 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -40,6 +40,7 @@ function publicPages(themes = []) {
{ path: '/native', changefreq: 'monthly', priority: 0.6, title: 'Apps for Android and iOS', text: 'How an OpenVibe page becomes a real Android or iOS screen: one set of tokens, components, patterns and icons, three size classes, what each platform does its own way, and the road from the web to apps.' },
{ path: '/motion', changefreq: 'monthly', priority: 0.6, title: 'Motion', text: 'How OpenVibe moves: the mark\'s motion and one shared kit of effects (glint, glow, lift, press, ripple, tilt, reveal, count, confetti), the timing every site shares, the rules and the code (openvibe-shared/ov-fx).' },
{ 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: '/fonts', changefreq: 'weekly', priority: 0.7, title: 'Open font library', text: 'Browse open licence font families by category, script and weight; preview every style and find its source and licence.' },
{ 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: '/icons/migrate', changefreq: 'monthly', priority: 0.5, title: 'Switch from Font Awesome, Lucide or Material', text: 'Paste your HTML or code and see every Font Awesome, Lucide, Material or MUI icon it uses with its free OpenVibe twin.' },
{ 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.' },
@@@ -90,6 +91,7 @@ function createDiscoveryRoutes(ctx) {
{ 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, 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>); style=auto|line|solid (auto: the solid drawing at 18 px and under); /icons/<name>.mjs (ES module), .xml (Android VectorDrawable), /icons/<name>/favicon.zip; the <ov-icon name=\"…\"> web component at /ov-icon.js; the registry as data at GET /api/v1/glyphs (?names=a,fa-b,lucide:c,material:d for just those); POST /api/v1/glyphs/migrate {text} lists every Font Awesome, Lucide, Material or MUI icon in a page or code with its OpenVibe twin (/icons/migrate is the page). All open, no sign-in, CORS *', html: true },
++ { title: 'Open font library', url: abs('/fonts'), note: 'Open licence typefaces with live previews, every style, licence, source, CSS and download. Search and filters at /fonts; JSON at GET /api/v1/fonts and /api/v1/fonts/<family>; MCP tools list and get fonts.' },
{ 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', html: true },
{ title: 'Loading', url: abs('/loading'), note: 'how OpenVibe waits: placeholders shaped like the content (kit.skeleton: card, row, tile, chip, line, chat, stat), the mark at work instead of a spinner (OpenVibeLoading), the navbar while a page moves (OpenVibeNavbar.busy), and the rules' },
{ title: 'Forms', url: abs('/forms'), note: 'OpenVibe’s own select (a menu on a computer, a sheet on a phone; options with state, badge and a setup link), switches, fields and choice cards: ov-forms.js and ov-forms.css' },
@@@ -101,6 +103,7 @@ function createDiscoveryRoutes(ctx) {
// The data an agent came for is a main section, not something to skip.
{ title: 'Machine-readable', links: [
{ title: 'The theme catalogue (JSON)', url: abs('/api/themes'), note: 'public, cached for 5 minutes' },
++ { title: 'Font catalogue (JSON)', url: abs('/api/v1/fonts'), note: 'open licence families, cached for 5 minutes' },
{ title: 'The Arena dataset (JSONL)', url: abs('/api/v1/arena/dataset.jsonl'), note: 'prompts, outputs and votes, no voters' },
{ title: 'Agent manifest', url: abs('/.well-known/agent.json') },
] },
ddiff --git a/server/http/fonts.js b/server/http/fonts.js
nnew file mode 100644
iindex 0000000..55a9d2c
---- /dev/null
++++ b/server/http/fonts.js
@@@ -0,0 +1,29 @@
++'use strict';
++
++// GET /api/v1/fonts?q=&category=&script=&weight= — open licence font families.
++// GET /api/v1/fonts/:family — metadata, CDN file URLs and three rule based pairings.
++const { asyncRouter } = require('./router');
++const views = require('../views/fonts');
++
++function createFontRoutes({ fonts }) {
++ const r = asyncRouter();
++ const filters = req => ({ q: String(req.query.q || '').slice(0, 100), category: String(req.query.category || '').slice(0, 40),
++ script: String(req.query.script || '').slice(0, 40), weight: String(req.query.weight || '').slice(0, 4) });
++ r.get('/api/v1/fonts', async (req, res) => {
++ const list = await fonts.list(filters(req));
++ res.set('Access-Control-Allow-Origin', '*').set('Cache-Control', 'public, max-age=300').json({ fonts: list, count: list.length });
++ });
++ r.get('/api/v1/fonts/:family', async (req, res) => {
++ const font = await fonts.get(req.params.family);
++ res.set('Access-Control-Allow-Origin', '*').set('Cache-Control', 'public, max-age=300');
++ return font ? res.json(font) : res.status(404).json({ error: 'Font family not found' });
++ });
++ r.get('/fonts', async (req, res) => res.set('Cache-Control', 'public, max-age=120').send(views.index(await fonts.list(), filters(req))));
++ r.get('/fonts/:family', async (req, res) => {
++ const font = await fonts.get(req.params.family);
++ return font ? res.set('Cache-Control', 'public, max-age=120').send(views.detail(font)) : res.status(404).send('Font family not found');
++ });
++ return r;
++}
++
++module.exports = { createFontRoutes };
ddiff --git a/server/http/mcp.js b/server/http/mcp.js
iindex a5a2138..2bfff4e 100644
---- a/server/http/mcp.js
++++ b/server/http/mcp.js
@@@ -53,6 +53,13 @@ const TOOLS = [
{ name: 'openvibe_design_icon_migrate', description: 'Read HTML or code and list every Font Awesome, Lucide, Material or MUI icon it uses, with its OpenVibe twin and how often it appears. Read only.', inputSchema: schema(['text'], {
text: { type: 'string', minLength: 1, maxLength: 400000 },
}) },
++ { name: 'openvibe_design_list_fonts', description: 'List open licence font families with category, scripts, weights, licence and source.', inputSchema: schema([], {
++ query: { type: 'string', maxLength: 100 }, category: { type: 'string', maxLength: 40 },
++ script: { type: 'string', maxLength: 40 }, weight: { type: 'integer', minimum: 100, maximum: 900 },
++ }) },
++ { name: 'openvibe_design_get_font', description: 'Get one font family with every style, CSS files, licence and three suggested pairings.', inputSchema: schema(['family'], {
++ family: { type: 'string', minLength: 1, maxLength: 100 },
++ }) },
].map(tool => ({ ...tool, annotations: { readOnlyHint: true, openWorldHint: false } }));
const rpc = (id, result) => ({ jsonrpc: '2.0', id, result });
@@@ -66,7 +73,7 @@ function toolResult(value) {
};
}
--async function callTool(name, args, { catalog }) {
++async function callTool(name, args, { catalog, fonts }) {
if (!object(args)) return toolError('Arguments must be an object.');
switch (name) {
case 'openvibe_design_list_themes': {
@@@ -130,6 +137,17 @@ async function callTool(name, args, { catalog }) {
case 'openvibe_design_icon_migrate':
if (typeof args.text !== 'string' || !args.text || args.text.length > 400000) return toolError('text must be a nonempty string of at most 400,000 characters.');
return toolResult({ icons: aliases.scan(args.text), page: `${BASE}/icons/migrate` });
++ case 'openvibe_design_list_fonts': {
++ if (typeof args.query !== 'undefined' && (typeof args.query !== 'string' || args.query.length > 100)) return toolError('query must be at most 100 characters.');
++ if (args.weight !== undefined && (!Number.isInteger(args.weight) || args.weight < 100 || args.weight > 900)) return toolError('weight must be 100 to 900.');
++ const list = await fonts.list({ q: args.query, category: args.category, script: args.script, weight: args.weight });
++ return toolResult({ fonts: list, count: list.length });
++ }
++ case 'openvibe_design_get_font': {
++ if (typeof args.family !== 'string' || !/^[a-z0-9-]{1,100}$/.test(args.family)) return toolError('family must be a font slug.');
++ const font = await fonts.get(args.family);
++ return font ? toolResult(font) : toolError('No font family by that slug.');
++ }
default:
return null;
}
ddiff --git a/server/index.js b/server/index.js
iindex 57c0ab9..68929e3 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -22,6 +22,7 @@ function createLifecycle({ server, ctx, exit, signals } = {}) {
deadlineMs: 10_000,
close: [
() => ctx.jobs.stop(),
++ () => ctx.fonts.stop(),
() => ctx.searchIndex.stop(),
() => ctx.keys.client.stop(),
() => ctx.s.close(),
@@@ -41,6 +42,7 @@ async function start() {
ctx.keys.ensure().catch(() => {});
// Generate queued Arena rounds. DESIGN_DRILL=1 keeps the loop off (a test or a drill drives tick()).
ctx.jobs.start();
++ if (!config.drill) ctx.fonts.start();
// The icon and theme pages in OpenVibe.Search: a sweep a minute after boot and every six hours.
if (!config.drill) ctx.searchIndex.start();
ctx.designs.resume().catch((err) => console.warn('[OpenVibe.Design] resume designs:', err.message));
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 6c57c47..4b51ab2 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -33,6 +33,7 @@ const NAV = [
{ label: 'Loading', href: '/loading', icon: 'fa-hourglass-half', more: true },
{ label: 'Forms', href: '/forms', icon: 'fa-list-check', more: true },
{ label: 'Icons', href: '/icons', icon: 'fa-shapes', more: true },
++ { label: 'Fonts', href: '/fonts', icon: 'fa-font', more: true },
{ label: 'Patterns', href: '/patterns', icon: 'fa-shapes', more: true },
{ label: 'Kit', href: '/kit', icon: 'fa-cubes', more: true },
{ label: 'Components', href: '/components', icon: 'fa-puzzle-piece', more: true },
@@@ -49,7 +50,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 = [], image, imageAlt, shared = [], sharedStyles = [] } = {}) {
++function layout({ title, description, canonical, path, robots = 'index, follow', body, active = '/', scripts = [], styles = [], jsonLd = [], inlineStyles = [], preloads = [], image, imageAlt, shared = [], sharedStyles = [] } = {}) {
const pathNow = path || active || '/';
canonical = canonical || (path ? `${BASE}${path}` : undefined);
const navbar = {
@@@ -87,6 +88,7 @@ function layout({ title, description, canonical, path, robots = 'index, follow',
${(sharedStyles || []).map((file) => `<link rel="stylesheet" href="${esc(ovServe.url(file))}">`).join('\n')}
${(styles || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">`).join('\n')}
${(inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
++${preloads.map(p => `<link rel="preload" as="font" type="font/woff2" href="${esc(p.url)}" crossorigin integrity="${esc(p.sri)}">`).join('\n')}
<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
<script src="${esc(ovServe.url('motion.js'))}" defer></script>
${(shared || []).map((file) => `<script src="${esc(ovServe.url(file))}" defer></script>`).join('\n')}
ddiff --git a/server/views/fonts.js b/server/views/fonts.js
nnew file mode 100644
iindex 0000000..5a65321
---- /dev/null
++++ b/server/views/fonts.js
@@@ -0,0 +1,60 @@
++'use strict';
++
++const { layout, esc } = require('../render/layout');
++
++const previewFile = f => f.files['400-normal'] || Object.values(f.files)[0];
++const face = (f, weight = 400, style = 'normal') => {
++ const url = f.files[`${weight}-${style}`];
++ return url ? `@font-face{font-family:${JSON.stringify(f.family)};src:url(${JSON.stringify(url)}) format("woff2");font-weight:${weight};font-style:${style};font-display:swap}` : '';
++};
++
++function index(fonts, filters) {
++ const categories = ['sans-serif', 'serif', 'display', 'handwriting', 'monospace'];
++ const scripts = [...new Set(fonts.flatMap(f => f.subsets))].sort();
++ const shown = fonts.filter(f => (!filters.q || `${f.family} ${f.designer}`.toLowerCase().includes(filters.q.toLowerCase()))
++ && (!filters.category || f.category === filters.category)
++ && (!filters.script || f.subsets.includes(filters.script))
++ && (!filters.weight || f.weights.includes(Number(filters.weight))));
++ const option = (values, selected, label) => `<label>${label}<select name="${label.toLowerCase()}"><option value="">All ${label.toLowerCase()}s</option>${values.map(v => `<option value="${esc(v)}"${v === selected ? ' selected' : ''}>${esc(String(v))}</option>`).join('')}</select></label>`;
++ const cards = shown.map(f => `<article class="fo-card"><a class="fo-card-link" href="/fonts/${encodeURIComponent(f.id)}">
++<h2 style="font-family:${esc(JSON.stringify(f.family))}">${esc(f.family)}</h2>
++<p class="fo-sample" style="font-family:${esc(JSON.stringify(f.family))}">${esc('The quick brown fox jumps over the lazy dog.')}</p>
++<p class="fo-meta"><span>${esc(f.category)}</span> · ${f.weights.length} ${f.weights.length === 1 ? 'weight' : 'weights'} · ${esc(f.license)}</p></a></article>`).join('');
++ return layout({ title: 'Open font library', description: 'Browse open licence typefaces by category, script and weight.',
++ path: '/fonts', active: '/fonts', styles: ['css/fonts.css'], scripts: ['js/fonts.js'],
++ inlineStyles: [shown.map(f => face(f, f.files['400-normal'] ? 400 : f.weights[0], 'normal')).join('\n')],
++ body: `<div class="fo"><header class="fo-hero"><p class="fo-eyebrow">The font library</p><h1>Find a face for your idea.</h1><p>Open licence typefaces with their source and licence beside them.</p></header>
++<nav class="fo-chips" aria-label="Category"><a href="/fonts${filters.q ? `?q=${encodeURIComponent(filters.q)}` : ''}"${!filters.category ? ' aria-current="page"' : ''}>All <span>${fonts.length}</span></a>${categories.filter(c => fonts.some(f => f.category === c)).map(c => `<a href="/fonts?category=${encodeURIComponent(c)}${filters.q ? `&q=${encodeURIComponent(filters.q)}` : ''}"${filters.category === c ? ' aria-current="page"' : ''}>${esc(c)} <span>${fonts.filter(f => f.category === c).length}</span></a>`).join('')}</nav>
++<form class="fo-filters" method="get" action="/fonts">${filters.category ? `<input type="hidden" name="category" value="${esc(filters.category)}">` : ''}<label class="fo-q">Search<input type="search" name="q" value="${esc(filters.q || '')}" placeholder="Family or designer"></label>
++${option(scripts, filters.script, 'Script')}${option([100,200,300,400,500,600,700,800,900], Number(filters.weight) || '', 'Weight')}
++<button type="submit">Filter</button></form>
++<label class="fo-try">Try your own words<input type="text" data-font-sample maxlength="140" placeholder="The quick brown fox jumps over the lazy dog."></label>
++<p class="fo-count">${shown.length} of ${fonts.length} families</p><div class="fo-grid">${cards || '<p>No families match these filters.</p>'}</div></div>` });
++}
++
++function detail(f) {
++ const style = ([key, url]) => {
++ const [weight, kind] = key.split('-');
++ return `<div class="fo-style"><div><b>${esc(weight)}${kind === 'italic' ? ' italic' : ''}</b><a href="${esc(url)}" download aria-label="Download ${esc(f.family)} ${esc(weight)} ${esc(kind)} (WOFF2)">WOFF2</a></div><p style="font-family:${esc(JSON.stringify(f.family))};font-weight:${weight};font-style:${kind}">The quick brown fox jumps over the lazy dog.</p></div>`;
++ };
++ // Upright first, lightest to heaviest, then the italics: the order a person reads a family in.
++ const entries = Object.entries(f.files).sort(([a], [b]) => { const [wa, sa] = a.split('-'), [wb, sb] = b.split('-'); return (sa === sb ? 0 : sa === 'normal' ? -1 : 1) || Number(wa) - Number(wb); });
++ const firstStyles = entries.slice(0, 6), moreStyles = entries.slice(6);
++ const styles = `${firstStyles.map(style).join('')}`;
++ const stylesMore = moreStyles.length ? `<details class="fo-more"><summary>More styles <span>(${moreStyles.length})</span></summary><div class="fo-styles">${moreStyles.map(style).join('')}</div></details>` : '';
++ const url = previewFile(f);
++ const css = `@font-face {\n font-family: ${JSON.stringify(f.family)};\n src: url("${url}") format("woff2");\n font-weight: 400;\n font-style: normal;\n font-display: swap;\n}`;
++ return layout({ title: f.family, description: `${f.family} by ${f.designer}: ${f.license} font, styles, CSS and download.`,
++ path: `/fonts/${f.id}`, active: '/fonts', styles: ['css/fonts.css'], scripts: ['js/fonts.js'],
++ preloads: f.sri ? [{ url, sri: f.sri }] : [],
++ inlineStyles: [Object.keys(f.files).map(key => { const [w, s] = key.split('-'); return face(f, Number(w), s); }).join('\n')
++ + f.pairings.filter(p => p.file).map(p => `@font-face{font-family:${JSON.stringify(p.family)};src:url(${JSON.stringify(p.file)}) format("woff2");font-weight:${Number(p.weight) || 700};font-style:normal;font-display:swap}`).join('\n')],
++ body: `<div class="fo fo-detail"><a class="fo-back" href="/fonts">← All fonts</a><header class="fo-hero"><p class="fo-eyebrow">${esc(f.category)} · ${esc(f.version || '')}</p><h1 style="font-family:${esc(JSON.stringify(f.family))}">${esc(f.family)}</h1><p>Designed by ${esc(f.designer)}</p></header>
++<label class="fo-try">Try your own words<input type="text" data-font-sample maxlength="140" placeholder="The quick brown fox jumps over the lazy dog."></label>
++<section><h2>Every style <span class="fo-n">${entries.length}</span></h2><div class="fo-styles">${styles}</div>${stylesMore}</section>
++<section class="fo-details"><div><h2>Use this font</h2><pre><code>${esc(css)}</code></pre><p><a href="${esc(url)}" download>Download the regular WOFF2</a>${f.sri ? `<span class="fo-integrity">SHA-384 integrity: <code>${esc(f.sri)}</code></span>` : ''}</p></div>
++<div><h2>Licence and source</h2><p>${esc(f.license)} · <a href="${esc(f.license_url)}" rel="noopener">Licence and source files</a></p><p><a href="${esc(f.source)}" rel="noopener">${esc(f.source)}</a></p><p>Scripts: ${f.subsets.map(esc).join(', ')}</p><p>Weights: ${f.weights.join(', ')} · Styles: ${f.styles.map(esc).join(', ')}</p></div></section>
++<section><h2>Suggested pairings</h2><p class="fo-note">A heading in the other face over ${esc(f.family)} for the text, chosen by contrast of form, x-height and era. Suggestions, not rules.</p><div class="fo-pairings">${f.pairings.map(p => `<article><p class="fo-pair-h" style="font-family:${esc(JSON.stringify(p.family))};font-weight:${Number(p.weight) || 700}">A heading in ${esc(p.family)}</p><p class="fo-pair-b" style="font-family:${esc(JSON.stringify(f.family))}">And the text under it in ${esc(f.family)}, the way a reader meets them together on a page.</p><p class="fo-pair-why"><a href="/fonts/${encodeURIComponent(p.id)}">${esc(p.family)}</a> · ${esc(p.reason)}</p></article>`).join('')}</div></section></div>` });
++}
++
++module.exports = { index, detail };
ddiff --git a/test/fonts.test.js b/test/fonts.test.js
nnew file mode 100644
iindex 0000000..0d1a855
---- /dev/null
++++ b/test/fonts.test.js
@@@ -0,0 +1,87 @@
++'use strict';
++
++const assert = require('node:assert/strict');
++const { boot, check, done } = require('./helpers/boot');
++const { testDb } = require('./helpers/db');
++const { createFontCatalog, validFont, due, WEEK } = require('../server/fonts/catalog');
++const seed = require('../server/fonts/seed.json');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the seed contains only licensed families with source and downloadable files', () => {
++ assert.equal(seed.length, 38);
++ assert.ok(seed.every(validFont));
++ assert.ok(seed.every(f => f.designer && f.version && f.source && f.sri));
++ assert.deepEqual(new Set(seed.map(f => f.license)), new Set(['OFL-1.1', 'Apache-2.0', 'UFL-1.0']));
++ });
++ await check('font pages show live samples, every style, CSS, licence, download and pairings', async () => {
++ const index = await t.get('/fonts');
++ assert.equal(index.status, 200);
++ assert.match(index.text, /Find a face for your idea/);
++ assert.match(index.text, /data-font-sample/);
++ assert.match(index.text, /fonts\/inter/);
++ const detail = await t.get('/fonts/inter');
++ assert.equal(detail.status, 200);
++ for (const text of ['Every style', 'OFL-1.1', 'Download the regular WOFF2', 'aria-label="Download Inter 400 normal (WOFF2)"', 'More styles <span>(', 'A heading in ', '@font-face', 'Suggested pairings', 'SHA-384 integrity', 'integrity="sha384-']) assert.ok(detail.text.includes(text), text);
++ assert.equal((await t.get('/fonts/no-such-family')).status, 404);
++ });
++ await check('the API filters by category, script and weight and rejects missing families', async () => {
++ const all = await t.get('/api/v1/fonts');
++ assert.equal(all.status, 200);
++ assert.equal(all.json().count, 38);
++ assert.ok(all.json().fonts.every(f => ['OFL-1.1', 'Apache-2.0', 'UFL-1.0'].includes(f.license)));
++ const arabic = (await t.get('/api/v1/fonts?script=arabic&weight=400')).json().fonts;
++ assert.ok(arabic.length && arabic.every(f => f.subsets.includes('arabic') && f.weights.includes(400)));
++ const serif = (await t.get('/api/v1/fonts?category=serif')).json().fonts;
++ assert.ok(serif.length && serif.every(f => f.category === 'serif'));
++ const inter = (await t.get('/api/v1/fonts/inter')).json();
++ assert.equal(inter.pairings.length, 3);
++ assert.ok(inter.pairings.every(p => p.reason && p.id !== 'inter'));
++ assert.equal((await t.get('/api/v1/fonts/no-such-family')).status, 404);
++ });
++ await check('MCP and llms.txt expose the catalogue', async () => {
++ const mcp = await t.get('/mcp', { json: { jsonrpc: '2.0', id: 1, method: 'tools/list' } });
++ assert.ok(mcp.json().result.tools.some(x => x.name === 'openvibe_design_get_font'));
++ assert.ok((await t.get('/llms.txt')).text.includes('/api/v1/fonts'));
++ });
++ } finally { await t.close(); }
++
++ await check('weekly refresh is database claimed and filters revoked licences', async () => {
++ const test = await testDb();
++ try {
++ let time = Date.UTC(2026, 10, 8);
++ let blocked = 'inter';
++ const fakeFetch = async url => {
++ if (url.startsWith('https://api.fontsource.org/')) {
++ const f = seed.find(x => url.endsWith(`/${x.id}`));
++ const variants = {};
++ for (const [key, link] of Object.entries(f.files)) {
++ const [weight, style] = key.split('-');
++ variants[weight] ||= {};
++ variants[weight][style] = { latin: { url: { woff2: link } } };
++ }
++ return { ok: true, json: async () => ({ ...f, license: f.id === blocked ? 'CC0-1.0' : f.license,
++ defSubset: 'latin', variants }) };
++ }
++ if (url.endsWith('/METADATA.pb')) return { ok: true, text: async () => 'designer: "Test Designer"\n' };
++ return { ok: true, arrayBuffer: async () => Buffer.from('font bytes') };
++ };
++ const options = { db: test.db, fetchImpl: fakeFetch, now: () => time };
++ const a = createFontCatalog(options), b = createFontCatalog(options);
++ await a.seedMissing();
++ assert.equal(due({ last_success_at: null, claimed_until: null }, time), true);
++ assert.equal((await Promise.all([a.tick(), b.tick()])).filter(Boolean).length, 1);
++ assert.equal((await a.list()).length, 37);
++ assert.equal(await a.get(blocked), null);
++ assert.equal(await a.tick(), false);
++ await a.seedMissing();
++ assert.equal(await a.get(blocked), null, 'restart seeding cannot restore a revoked licence');
++ time += WEEK + 1;
++ blocked = null;
++ assert.equal(await b.tick(), true);
++ assert.equal((await b.list()).length, 38);
++ } finally { await test.close(); }
++ });
++ done();
++})().catch(e => { console.error(e); process.exit(1); });
ddiff --git a/test/mcp.test.js b/test/mcp.test.js
iindex 80ed6f5..23ca347 100644
---- a/test/mcp.test.js
++++ b/test/mcp.test.js
@@@ -23,6 +23,7 @@ const components = require('openvibe-shared/components');
'openvibe_design_list_components', 'openvibe_design_get_component',
'openvibe_design_validate_kit_document',
'openvibe_design_icon_search', 'openvibe_design_icon_svg', 'openvibe_design_icon_code', 'openvibe_design_icon_migrate',
++ 'openvibe_design_list_fonts', 'openvibe_design_get_font',
]);
assert.ok(listed.every(x => x.inputSchema && x.annotations.readOnlyHint));
assert.strictEqual((await t.get('/mcp', { method: 'OPTIONS' })).status, 204);