diff --git a/README.md b/README.md
index d9b8bf9..914e146 100644
--- a/README.md
+++ b/README.md
@@ -57,11 +57,16 @@ An element is finished when it has all of these, in one PR:
- `pitch` — one sentence in the client's words, the lead of the public page. `summary` stays
the gallery's technical line.
- `usedOn` with at least one site, and `added` as `YYYY-MM-DD`.
+ - `category` — one of `CATEGORIES`. It decides the element's section on the
+ superherotech.ai hub and its mega-menu column, and the skill mirrors it. Add the element to
+ `GOLDEN` in `scripts/check-categories.mjs` in the same category; the check fails until you do.
4. **`npm run check` green.** It verifies every entry's file exists, every entry has a demo in
the index, every demo asset exists, ids are kebab-case and unique, each `search.query` is
unique and not another entry's `alsoRanks`, `added` is a date, `usedOn` is non-empty, and
that `astro build` produces one page per entry. It exits 1 with a sentence naming the entry
- and the rule.
+ and the rule. `scripts/check-categories.mjs` runs first: every element has a known category,
+ every category has at least three elements, the assignment matches `GOLDEN`, and the
+ categories' order and labels are unchanged.
5. **Its section in the `superhero-ui-library` skill**, with the plugin version bumped. The
check cannot see agent-skills, so this one is on you; it is still part of "finished".
@@ -80,7 +85,7 @@ and imports exactly these paths:
| Path | What the website takes from it |
| --- | --- |
-| `src/data/catalog.ts` | `catalog` (and `byId`): names, `search`, `pitch`, `aka`, `replaces`, good for / not for, `usedOn` |
+| `src/data/catalog.ts` | `catalog` (and `byId`): names, `search`, `pitch`, `aka`, `replaces`, good for / not for, `usedOn`, `category`; `CATEGORIES` for the hub sections and mega-menu columns |
| `src/components/demos/index.ts` | `demos`, keyed by element `id` |
| `src/library//.astro` | each entry's `file`, imported by its demo |
| `public/demo/*` | demo assets, copied to the site's `public/elements-demo/`; the site passes `assetBase="/elements-demo/"` to each demo |
diff --git a/package.json b/package.json
index 19207fd..bc6a959 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"preview": "astro preview",
"astro": "astro",
"build:icon-data": "node scripts/build-icon-data.mjs",
- "check": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-catalog.mjs && node --disable-warning=ExperimentalWarning scripts/check-business-hours.mjs && node scripts/check-news-ticker.mjs && node --disable-warning=ExperimentalWarning scripts/check-flip-box.mjs && node scripts/check-social-grid.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-four.mjs && node --disable-warning=ExperimentalWarning scripts/check-video-player.mjs && node scripts/check-cookie-consent.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-icon.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-animated-background.mjs && node --disable-warning=ExperimentalWarning scripts/check-modal-and-bar.mjs && node --disable-warning=ExperimentalWarning scripts/check-reveal-cards.mjs && node --disable-warning=ExperimentalWarning scripts/check-time-and-toggles.mjs && node --disable-warning=ExperimentalWarning scripts/check-media-and-links.mjs && node --disable-warning=ExperimentalWarning scripts/check-structure.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-feedback.mjs && node --disable-warning=ExperimentalWarning scripts/check-controls.mjs && node scripts/check-small-pieces.mjs"
+ "check": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-categories.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-catalog.mjs && node --disable-warning=ExperimentalWarning scripts/check-business-hours.mjs && node scripts/check-news-ticker.mjs && node --disable-warning=ExperimentalWarning scripts/check-flip-box.mjs && node scripts/check-social-grid.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-four.mjs && node --disable-warning=ExperimentalWarning scripts/check-video-player.mjs && node scripts/check-cookie-consent.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-icon.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-animated-background.mjs && node --disable-warning=ExperimentalWarning scripts/check-modal-and-bar.mjs && node --disable-warning=ExperimentalWarning scripts/check-reveal-cards.mjs && node --disable-warning=ExperimentalWarning scripts/check-time-and-toggles.mjs && node --disable-warning=ExperimentalWarning scripts/check-media-and-links.mjs && node --disable-warning=ExperimentalWarning scripts/check-structure.mjs && node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/check-feedback.mjs && node --disable-warning=ExperimentalWarning scripts/check-controls.mjs && node scripts/check-small-pieces.mjs"
},
"dependencies": {
"astro": "^7.2.1"
diff --git a/scripts/check-categories.mjs b/scripts/check-categories.mjs
new file mode 100644
index 0000000..4011279
--- /dev/null
+++ b/scripts/check-categories.mjs
@@ -0,0 +1,238 @@
+#!/usr/bin/env node
+/**
+ * npm run check — pins the one category every element is listed under. superherotech.ai's
+ * /elements/ hub sections and its mega-menu columns are built from `category` and `CATEGORIES`
+ * in src/data/catalog.ts, and the `superhero-ui-library` skill mirrors them, so a change here is
+ * a change in three places and must be deliberate.
+ *
+ * - CATEGORIES: exactly the seven ids below, in this order (display order), with these labels;
+ * every blurb one non-empty sentence that does not say "component". The `CategoryId` type in
+ * the source lists the same ids in the same order (Node strips types, so it is read as text).
+ * - Every element has a `category`, and it is one of CATEGORIES.
+ * - Every category has at least 3 elements.
+ * - The full id -> category assignment equals GOLDEN. A new element fails here until it is
+ * placed in GOLDEN, which is the point: someone has to choose its category.
+ *
+ * Mutation tests: the rules run again on deliberately broken copies (see `mutants`); every
+ * mutant must be caught, or the check itself is broken.
+ *
+ * Exits 1 with one line per failure.
+ */
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..');
+const catalogPath = join(root, 'src/data/catalog.ts');
+const { catalog, CATEGORIES } = await import(pathToFileURL(catalogPath).href);
+
+/** Display order and labels, as agreed in the hub redesign. */
+const ORDER = [
+ ['motion', 'Hero & motion'],
+ ['navigation', 'Navigation & layout'],
+ ['content', 'Content blocks'],
+ ['media', 'Media & showcase'],
+ ['conversion', 'Offers & conversion'],
+ ['feedback', 'Feedback & states'],
+ ['controls', 'Controls & details'],
+];
+
+/** Every element's category. Add a new element here, in the category you chose for it. */
+const GOLDEN = {
+ 'particle-field': 'motion',
+ 'video-background': 'motion',
+ 'parallax-band': 'motion',
+ 'scroll-reveal': 'motion',
+ 'animated-background': 'motion',
+ 'animated-text': 'motion',
+
+ 'mega-menu': 'navigation',
+ 'off-canvas': 'navigation',
+ tabs: 'navigation',
+ tabcordion: 'navigation',
+ accordion: 'navigation',
+ scrollbox: 'navigation',
+
+ 'info-list': 'content',
+ 'flip-box': 'content',
+ 'slide-box': 'content',
+ hotspot: 'content',
+ 'info-circle': 'content',
+ 'business-hours': 'content',
+ map: 'content',
+ 'responsive-table': 'content',
+
+ 'before-after': 'media',
+ 'video-player': 'media',
+ 'video-gallery': 'media',
+ 'social-grid': 'media',
+ 'card-slider': 'media',
+ 'testimonial-carousel': 'media',
+ icon: 'media',
+
+ countdown: 'conversion',
+ 'announcement-bar': 'conversion',
+ modal: 'conversion',
+ 'content-toggle': 'conversion',
+ 'news-ticker': 'conversion',
+ sticker: 'conversion',
+
+ notice: 'feedback',
+ toast: 'feedback',
+ loading: 'feedback',
+ tooltip: 'feedback',
+ stepper: 'feedback',
+
+ 'radio-group': 'controls',
+ 'menu-button': 'controls',
+ tags: 'controls',
+ 'link-effects': 'controls',
+ 'cookie-consent': 'controls',
+};
+
+const MIN_PER_CATEGORY = 3;
+
+/** The rules, as a pure function so the mutants can run them. Returns one sentence per failure. */
+function categoryFailures(elements, categories, typeIds) {
+ const out = [];
+ const fail = (m) => out.push(m);
+
+ // CATEGORIES: order, labels, blurbs.
+ if (!Array.isArray(categories)) {
+ fail('src/data/catalog.ts does not export a `CATEGORIES` array.');
+ return out;
+ }
+ const gotOrder = categories.map((c) => c?.id).join(', ');
+ const wantOrder = ORDER.map(([id]) => id).join(', ');
+ if (gotOrder !== wantOrder) fail(`CATEGORIES is in the order [${gotOrder}]; the hub and the mega-menu expect [${wantOrder}].`);
+ for (const [id, label] of ORDER) {
+ const c = categories.find((x) => x?.id === id);
+ if (!c) continue; // reported by the order rule
+ if (c.label !== label) fail(`Category "${id}" is labelled "${c.label}"; the agreed label is "${label}".`);
+ const blurb = typeof c.blurb === 'string' ? c.blurb.trim() : '';
+ if (!blurb) fail(`Category "${id}" has no blurb, the sentence shown under its heading.`);
+ else {
+ if (!/[.!?]$/.test(blurb) || /[.!?]\s+[A-Z]/.test(blurb)) fail(`Category "${id}" has a blurb that is not one sentence.`);
+ if (/\bcomponents?\b/i.test(blurb)) fail(`Category "${id}" says "component" in its blurb; write what a visitor gets.`);
+ }
+ }
+ if (typeIds !== undefined && typeIds.join(', ') !== wantOrder) {
+ fail(`The CategoryId type lists [${typeIds.join(', ')}]; it must list [${wantOrder}], the same ids in the same order as CATEGORIES.`);
+ }
+
+ // Every element has a known category; tally per category.
+ const known = new Set(categories.map((c) => c?.id));
+ const counts = new Map(ORDER.map(([id]) => [id, 0]));
+ for (const [i, e] of elements.entries()) {
+ const name = e?.id ? `"${e.id}"` : `entry #${i + 1}`;
+ if (e.category === undefined || e.category === null || e.category === '') {
+ fail(`Element ${name} has no \`category\`; every element is listed under exactly one.`);
+ continue;
+ }
+ if (!known.has(e.category)) {
+ fail(`Element ${name} has category "${e.category}", which is not in CATEGORIES.`);
+ continue;
+ }
+ counts.set(e.category, (counts.get(e.category) ?? 0) + 1);
+ }
+ for (const [id, n] of counts) {
+ if (known.has(id) && n < MIN_PER_CATEGORY) {
+ fail(`Category "${id}" has ${n} element${n === 1 ? '' : 's'}; a category needs at least ${MIN_PER_CATEGORY} to earn its section and menu column.`);
+ }
+ }
+
+ // The golden assignment, both ways.
+ const seen = new Set();
+ for (const e of elements) {
+ if (!e?.id) continue;
+ seen.add(e.id);
+ if (!(e.id in GOLDEN)) {
+ fail(`Element "${e.id}" is not in GOLDEN in scripts/check-categories.mjs; choose its category and add it there.`);
+ } else if (e.category !== undefined && e.category !== GOLDEN[e.id]) {
+ fail(`Element "${e.id}" has category "${e.category}"; GOLDEN places it in "${GOLDEN[e.id]}". Move it in both places, deliberately.`);
+ }
+ }
+ for (const id of Object.keys(GOLDEN)) {
+ if (!seen.has(id)) fail(`GOLDEN places "${id}", which is not in the catalogue; remove it from scripts/check-categories.mjs.`);
+ }
+ return out;
+}
+
+/** The CategoryId union, read from the source (Node strips types before import). */
+function typeIdsFrom(src) {
+ const m = src.match(/export type CategoryId\s*=\s*([^;]+);/);
+ return m ? [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]) : [];
+}
+
+const typeIds = typeIdsFrom(readFileSync(catalogPath, 'utf8'));
+const failures = categoryFailures(catalog, CATEGORIES, typeIds);
+if (failures.length) {
+ console.error('check-categories failed (catalogue):');
+ for (const f of failures) console.error(` - ${f}`);
+ process.exit(1);
+}
+
+// ---------------------------------------------------------------- mutation tests
+const clone = () => catalog.map((e) => ({ ...e }));
+const cats = () => CATEGORIES.map((c) => ({ ...c }));
+const at = (list, id) => list.find((e) => e.id === id);
+const mutants = [
+ {
+ name: 'one element with an unknown category',
+ expect: /"tabs" has category "widgets", which is not in CATEGORIES/,
+ run: () => { const c = clone(); at(c, 'tabs').category = 'widgets'; return categoryFailures(c, CATEGORIES, typeIds); },
+ },
+ {
+ name: 'one element with no category',
+ expect: /"toast" has no `category`/,
+ run: () => { const c = clone(); delete at(c, 'toast').category; return categoryFailures(c, CATEGORIES, typeIds); },
+ },
+ {
+ name: 'one element moved to another real category',
+ expect: /"map" has category "media"; GOLDEN places it in "content"/,
+ run: () => { const c = clone(); at(c, 'map').category = 'media'; return categoryFailures(c, CATEGORIES, typeIds); },
+ },
+ {
+ name: 'a new element nobody placed',
+ expect: /"brand-new" is not in GOLDEN/,
+ run: () => categoryFailures([...clone(), { id: 'brand-new', category: 'content' }], CATEGORIES, typeIds),
+ },
+ {
+ name: 'a category left with two elements',
+ expect: /Category "feedback" has 2 elements/,
+ run: () => categoryFailures(clone().filter((e) => !['notice', 'toast', 'loading'].includes(e.id)), CATEGORIES, typeIds),
+ },
+ {
+ name: 'two categories swapped in order',
+ expect: /CATEGORIES is in the order \[navigation, motion/,
+ run: () => { const c = cats(); [c[0], c[1]] = [c[1], c[0]]; return categoryFailures(catalog, c, typeIds); },
+ },
+ {
+ name: 'a relabelled category',
+ expect: /"media" is labelled "Gallery"/,
+ run: () => { const c = cats(); at(c, 'media').label = 'Gallery'; return categoryFailures(catalog, c, typeIds); },
+ },
+ {
+ name: 'a blurb that says component',
+ expect: /"controls" says "component"/,
+ run: () => { const c = cats(); at(c, 'controls').blurb = 'Every small component you need.'; return categoryFailures(catalog, c, typeIds); },
+ },
+ {
+ name: 'a CategoryId type that drifted from CATEGORIES',
+ expect: /The CategoryId type lists/,
+ run: () => categoryFailures(catalog, CATEGORIES, typeIdsFrom("export type CategoryId = 'motion' | 'navigation';")),
+ },
+];
+const missed = [];
+for (const m of mutants) {
+ const got = m.run();
+ if (!got.some((f) => m.expect.test(f))) missed.push(`mutant "${m.name}" was not caught${got.length ? ` (got: ${got[0]})` : ''}.`);
+}
+if (missed.length) {
+ console.error('check-categories failed (mutation tests):');
+ for (const f of missed) console.error(` - ${f}`);
+ process.exit(1);
+}
+
+const summary = ORDER.map(([id]) => `${id} ${catalog.filter((e) => e.category === id).length}`).join(', ');
+console.log(`check-categories ok: ${catalog.length} elements in ${ORDER.length} categories (${summary}); ${mutants.length} mutants caught.`);
diff --git a/src/data/catalog.ts b/src/data/catalog.ts
index 1b75a58..0637477 100644
--- a/src/data/catalog.ts
+++ b/src/data/catalog.ts
@@ -26,8 +26,57 @@ export interface Search {
/** Close variants the same page should rank for without targeting them. */
alsoRanks?: string[];
}
+/** The buyer-facing groups every element belongs to exactly one of. */
+export type CategoryId = 'motion' | 'navigation' | 'content' | 'media' | 'conversion' | 'feedback' | 'controls';
+/**
+ * The categories in display order, with the label and one-sentence blurb shown above each group.
+ * `npm run check` pins this order and these labels, and which element sits in which category.
+ */
+export const CATEGORIES: { id: CategoryId; label: string; blurb: string }[] = [
+ {
+ id: 'motion',
+ label: 'Hero & motion',
+ blurb: 'Movement that makes your first screen feel alive, from drifting particles and background video to words that type themselves.',
+ },
+ {
+ id: 'navigation',
+ label: 'Navigation & layout',
+ blurb: 'Ways to help visitors find their way and fit a lot onto one page without it ever feeling crowded.',
+ },
+ {
+ id: 'content',
+ label: 'Content blocks',
+ blurb: 'Tidy ways to present your services, opening hours, locations and facts so visitors take them in at a glance.',
+ },
+ {
+ id: 'media',
+ label: 'Media & showcase',
+ blurb: 'Frames for your photos, videos, reviews and social posts that invite people to look closer.',
+ },
+ {
+ id: 'conversion',
+ label: 'Offers & conversion',
+ blurb: 'Put your offer, deadline or big news in front of visitors at the moment they are ready to act.',
+ },
+ {
+ id: 'feedback',
+ label: 'Feedback & states',
+ blurb: 'Clear messages that tell visitors what just happened, what is still loading and how far along they are.',
+ },
+ {
+ id: 'controls',
+ label: 'Controls & details',
+ blurb: 'Small finishing touches, from choices and menus to link hovers and cookie consent, that make a site feel carefully made.',
+ },
+];
export interface Element {
id: string;
+ /**
+ * The one category the element is listed under. superherotech.ai's /elements/ hub sections and
+ * its mega-menu columns are driven by it, and the `superhero-ui-library` skill mirrors it, so
+ * changing an element's category changes all three.
+ */
+ category: CategoryId;
name: string;
aka: string[];
/** The gallery's technical line: what it is and how it behaves. */
@@ -57,6 +106,7 @@ export interface Element {
export const catalog: Element[] = [
{
id: 'before-after',
+ category: 'media',
name: 'Before / After',
aka: ['TwentyTwenty', 'image comparison slider', 'compare slider', 'UABB Before After', 'reveal slider'],
summary: 'Two images in one frame. Drag the handle, or use the arrow keys, to reveal one over the other.',
@@ -100,6 +150,7 @@ export const catalog: Element[] = [
},
{
id: 'particle-field',
+ category: 'motion',
name: 'Particle field',
aka: ['particles.js', 'animated particle background', 'constellation background', 'network dots', 'Smart Slider particle effect', 'UABB Particle Background', 'animated material background'],
summary: 'Slow-drifting dots that join up with fine lines when they come near each other, drawn on a canvas behind a hero or band.',
@@ -138,6 +189,7 @@ export const catalog: Element[] = [
},
{
id: 'mega-menu',
+ category: 'navigation',
name: 'Mega menu',
aka: ['Max Mega Menu', 'UberMenu', 'mega dropdown', 'Elementor mega menu', 'WP Mega Menu', 'mega navigation'],
summary: 'A header nav where a top item opens a full-width panel of grouped links, with column headings and an optional promo. Click or Enter opens it; on small screens it folds into an accordion behind a Menu button.',
@@ -200,6 +252,7 @@ export const catalog: Element[] = [
},
{
id: 'video-background',
+ category: 'motion',
name: 'Video background',
aka: ['video hero', 'background video', 'Elementor video background', 'autoplay muted loop video', 'Beaver Builder video row', 'hero video'],
summary: 'A muted, looping video behind a hero, over a poster, with an optional tint and a visible pause button. Only the poster loads on small screens, under reduced motion and without JavaScript.',
@@ -255,6 +308,7 @@ export const catalog: Element[] = [
},
{
id: 'parallax-band',
+ category: 'motion',
name: 'Parallax band',
aka: ['parallax section', 'parallax background row', 'Elementor motion effects', 'Beaver Builder parallax row', 'scroll-speed background', 'parallax background'],
summary: 'A full-width band whose background image scrolls slower than the page, with a tint and content on top. A scroll-driven transform, not background-attachment: fixed, so it works on iOS.',
@@ -299,6 +353,7 @@ export const catalog: Element[] = [
},
{
id: 'scroll-reveal',
+ category: 'motion',
name: 'Scroll reveal',
aka: ['AOS', 'ScrollReveal.js', 'animate on scroll', 'Elementor entrance animations', 'WOW.js', 'fade in on scroll'],
summary: 'A wrapper that reveals its children as they scroll into view: a fade with a small rise, a slide from a side, or a slight scale, staggered across a list. Content stays visible if the script never runs.',
@@ -346,6 +401,7 @@ export const catalog: Element[] = [
},
{
id: 'business-hours',
+ category: 'content',
name: 'Business hours',
aka: ['Business Hours Indicator', 'opening hours widget', 'open now / closed now', 'store hours', 'hours of operation', 'WP Business Hours'],
summary:
@@ -404,6 +460,7 @@ import { hours } from '../data/hours'; // export const hours: BusinessHoursDat
},
{
id: 'news-ticker',
+ category: 'conversion',
name: 'News ticker',
aka: ['Content Ticker', 'PowerPack Content Ticker', 'marquee', 'scrolling headlines', 'trending bar', 'announcement bar', 'ticker tape', 'text ticker'],
summary:
@@ -481,6 +538,7 @@ const posts = (await getCollection('blog')).sort((a, b) => +b.data.date - +a.dat
},
{
id: 'flip-box',
+ category: 'content',
name: 'Flip box',
aka: ['PowerPack Flip Box', 'UABB Flip Box', 'Elementor Flip Box', 'flip card', 'info box with hover reveal', '3D card flip'],
summary:
@@ -566,6 +624,7 @@ const posts = (await getCollection('blog')).sort((a, b) => +b.data.date - +a.dat
},
{
id: 'social-grid',
+ category: 'media',
name: 'Instagram feed',
aka: ['PowerPack Instagram Feed', 'Smash Balloon', 'Instagram feed widget', 'social feed', 'Elfsight Instagram', 'LightWidget'],
summary:
@@ -637,6 +696,7 @@ const feed = social as SocialFeed;
},
{
id: 'accordion',
+ category: 'navigation',
name: 'Accordion / FAQ',
aka: ['PowerPack FAQ Module', 'PowerPack Advanced Accordions', 'UABB Advanced Accordion', 'Elementor Accordion', 'FAQ accordion', 'FAQ schema', 'toggle', 'collapsible'],
summary:
@@ -710,6 +770,7 @@ const faqs: AccordionItem[] = [
},
{
id: 'card-slider',
+ category: 'media',
name: 'Card slider',
aka: ['PowerPack Card Slider', 'Elementor Testimonial Carousel', 'Slick slider', 'Swiper', 'Owl carousel', 'post carousel', 'team carousel'],
summary:
@@ -767,6 +828,7 @@ const faqs: AccordionItem[] = [
},
{
id: 'tabs',
+ category: 'navigation',
name: 'Tabs',
aka: ['PowerPack Advanced Tabs', 'UABB Advanced Tabs', 'Elementor Tabs', 'tabbed content', 'vertical tabs'],
summary:
@@ -829,6 +891,7 @@ const faqs: AccordionItem[] = [
},
{
id: 'info-list',
+ category: 'content',
name: 'Info list',
aka: ['PowerPack Info List', 'UABB Info List', 'Elementor Icon List', 'icon list', 'steps list', 'process steps', 'vertical timeline', 'feature list'],
summary:
@@ -888,6 +951,7 @@ const faqs: AccordionItem[] = [
},
{
id: 'video-player',
+ category: 'media',
name: 'Video player',
aka: ['PowerPack Video', 'UABB Video', 'Elementor Video', 'YouTube embed', 'Vimeo embed', 'video lightbox', 'lite YouTube embed'],
summary:
@@ -959,6 +1023,7 @@ const faqs: AccordionItem[] = [
},
{
id: 'testimonial-carousel',
+ category: 'media',
name: 'Testimonial carousel',
aka: ['testimonial slider', 'testimonial rotator', 'review scroller', 'reviews slider', 'Elementor Testimonial Carousel', 'Slick carousel', 'Owl Carousel testimonials', 'Strong Testimonials', 'rotating testimonials'],
summary: 'A row of quote cards, all in the HTML, that scrolls and snaps by swipe, by arrows or on its own. Autoplay waits on each card for its reading time, holds on hover, stops once the visitor takes hold of the row, and has a visible pause button.',
@@ -1024,6 +1089,7 @@ import { testimonials } from '../data/testimonials'; // [{ quote, name, meta }
},
{
id: 'icon',
+ category: 'media',
name: 'Icon library',
aka: ['Font Awesome', 'icon picker', 'icon search', 'Beaver Builder icon', 'UABB icon', 'Elementor icon widget', 'SVG icon'],
summary: 'One glyph from Font Awesome Free, inlined as a single SVG at build. No icon font, no CSS sprite, no client-side JavaScript.',
@@ -1063,6 +1129,7 @@ import { testimonials } from '../data/testimonials'; // [{ quote, name, meta }
},
{
id: 'animated-text',
+ category: 'motion',
name: 'Animated text',
aka: ['animated headline', 'Elementor Animated Headline', 'text rotator', 'word rotator', 'rotating text', 'typewriter effect', 'typing animation', 'Typed.js', 'fancy text', 'word reveal', 'highlighted headline'],
summary:
@@ -1147,6 +1214,7 @@ import AnimatedText from '../components/AnimatedText.astro';
},
{
id: 'cookie-consent',
+ category: 'controls',
name: 'Cookie consent',
aka: ['CookieYes', 'Cookiebot', 'Complianz', 'GDPR Cookie Consent', 'cookie banner', 'consent bar', 'consent mode'],
summary: 'A consent bar and the Google Tag Manager loader it gates. Opt-out mode (the default) loads analytics until the visitor declines; opt-in loads nothing from Google until they accept; visitors whose browser time zone is in the EEA, UK or Switzerland get opt-in whichever mode is set. Decline stops GA and deletes the cookies it already wrote. Synced from ecropolis-consent, never edited here.',
@@ -1236,6 +1304,7 @@ import { analytics } from '../data/site';
},
{
id: 'animated-background',
+ category: 'motion',
name: 'Animated background',
aka: ['Vanta.js', 'Vanta backgrounds', 'UABB Animated Background', 'PowerPack animated background', 'animated hero background', 'waves background', 'fog background', 'birds background'],
summary:
@@ -1288,6 +1357,7 @@ import { analytics } from '../data/site';
},
{
id: 'modal',
+ category: 'conversion',
name: 'Modal box',
aka: ['PowerPack Modal Box', 'UABB Modal Popup', 'Elementor Popup', 'popup', 'lightbox', 'exit-intent popup', 'OptinMonster'],
summary:
@@ -1358,6 +1428,7 @@ import { analytics } from '../data/site';
},
{
id: 'announcement-bar',
+ category: 'conversion',
name: 'Announcement bar',
aka: ['PowerPack Announcement Bar', 'Hello Bar', 'notification bar', 'top bar', 'promo bar', 'sticky bar', 'WP Notification Bar'],
summary:
@@ -1430,6 +1501,7 @@ import { analytics } from '../data/site';
},
{
id: 'hotspot',
+ category: 'content',
name: 'Image hotspots',
aka: ['UABB Hotspot', 'image hotspots', 'interactive image', 'image map', 'hotspot tour', 'product tour image', 'Elementor Hotspot', 'shoppable image'],
summary:
@@ -1499,6 +1571,7 @@ import { analytics } from '../data/site';
},
{
id: 'info-circle',
+ category: 'content',
name: 'Info circle',
aka: ['UABB Info Circle', 'circular infographic', 'circle infographic', 'process circle', 'cycle diagram', 'radial menu', 'interactive infographic'],
summary:
@@ -1561,6 +1634,7 @@ import { analytics } from '../data/site';
},
{
id: 'slide-box',
+ category: 'content',
name: 'Slide box',
aka: ['UABB Slide Box', 'slide box', 'reveal card', 'sliding card', 'hover reveal box', 'info box with slide-up detail', 'card overlay'],
summary:
@@ -1641,6 +1715,7 @@ import { analytics } from '../data/site';
},
{
id: 'countdown',
+ category: 'conversion',
name: 'Countdown',
aka: ['UABB Countdown', 'Elementor Countdown', 'countdown timer', 'evergreen countdown timer', 'sale timer', 'deadline timer', 'order cut-off timer'],
summary:
@@ -1712,6 +1787,7 @@ import { analytics } from '../data/site';
},
{
id: 'content-toggle',
+ category: 'conversion',
name: 'Content toggle',
aka: ['UABB Content Toggle', 'PowerPack Content Toggle', 'pricing toggle', 'monthly / annual switch', 'content switcher', 'toggle switch'],
summary:
@@ -1779,6 +1855,7 @@ import { analytics } from '../data/site';
},
{
id: 'off-canvas',
+ category: 'navigation',
name: 'Off-canvas panel',
aka: ['UABB Off-Canvas', 'Elementor Off-Canvas', 'slide-out panel', 'side drawer', 'push menu', 'offcanvas', 'flyout panel', 'slide-in cart'],
summary:
@@ -1848,6 +1925,7 @@ import { analytics } from '../data/site';
},
{
id: 'video-gallery',
+ category: 'media',
name: 'Video gallery',
aka: ['UABB Video Gallery', 'PowerPack Video Gallery', 'Elementor Video Playlist', 'YouTube gallery', 'Vimeo gallery', 'video grid', 'filterable video gallery'],
summary:
@@ -1926,6 +2004,7 @@ import { analytics } from '../data/site';
},
{
id: 'map',
+ category: 'content',
name: 'Map and directions',
aka: ['UABB Google Map', 'PowerPack Google Map', 'Elementor Google Maps', 'WP Google Maps', 'Google Maps embed', 'store locator', 'location card', 'get directions button'],
summary:
@@ -1998,6 +2077,7 @@ import LocationMap from '../components/LocationMap.astro'; // not "Map": that
},
{
id: 'link-effects',
+ category: 'controls',
name: 'Link effects',
aka: ['UABB Creative Link', 'PowerPack Link Effects', 'creative link', 'underline animation', 'link hover effects', 'animated underline', 'text hover effects'],
summary:
@@ -2058,6 +2138,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra
},
{
id: 'tabcordion',
+ category: 'navigation',
name: 'Tabcordion',
aka: ['responsive tabs', 'tabs to accordion', 'accordion tabs', 'tabs on mobile', 'product details tabs', 'Easy Responsive Tabs'],
summary:
@@ -2121,6 +2202,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra
},
{
id: 'tooltip',
+ category: 'feedback',
name: 'Tooltip',
aka: ['hover text', 'info icon', 'hint bubble', 'Tippy.js', 'Bootstrap tooltip', 'Elementor Hotspot tooltip'],
summary:
@@ -2179,6 +2261,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra
},
{
id: 'responsive-table',
+ category: 'content',
name: 'Responsive table',
aka: ['mobile table', 'table to cards', 'stacked table', 'TablePress responsive', 'data table', 'comparison table', 'price table'],
summary:
@@ -2249,6 +2332,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra
},
{
id: 'notice',
+ category: 'feedback',
name: 'Notice',
aka: ['alert box', 'info box', 'callout box', 'Bootstrap alert', 'message box', 'UABB Info Box', 'Elementor Alert widget', 'inline message'],
summary:
@@ -2307,6 +2391,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra
},
{
id: 'toast',
+ category: 'feedback',
name: 'Toast',
aka: ['snackbar', 'toast notification', 'toastr', 'Notyf', 'flash message', 'pop-up notification', 'growl notification'],
summary:
@@ -2373,6 +2458,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra
},
{
id: 'loading',
+ category: 'feedback',
name: 'Loading',
aka: ['spinner', 'loading spinner', 'skeleton screen', 'skeleton loader', 'preloader', 'busy indicator', 'loading animation', 'progress spinner'],
summary:
@@ -2439,6 +2525,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra
},
{
id: 'menu-button',
+ category: 'controls',
name: 'Menu button',
aka: ['dropdown button', 'action menu', 'kebab menu', 'three-dot menu', 'more options menu', 'Bootstrap dropdown'],
summary:
@@ -2511,6 +2598,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra
},
{
id: 'radio-group',
+ category: 'controls',
name: 'Radio group',
aka: ['chunky radio buttons', 'radio cards', 'card radio buttons', 'segmented control', 'segmented buttons', 'toggle button group', 'custom radio buttons', 'styled radio buttons', 'Gravity Forms radio', 'WPForms multiple choice'],
summary:
@@ -2580,6 +2668,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra
},
{
id: 'stepper',
+ category: 'feedback',
name: 'Stepper',
aka: ['step indicator', 'progress steps', 'stepped progress indicator', 'checkout steps', 'wizard steps', 'multi-step form progress bar', 'Gravity Forms progress bar', 'WPForms page break progress'],
summary:
@@ -2645,6 +2734,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra
},
{
id: 'tags',
+ category: 'controls',
name: 'Tags',
aka: ['chips', 'tag chips', 'pills', 'badges list', 'filter chips', 'removable tags', 'post tags', 'tag cloud'],
summary:
@@ -2707,6 +2797,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra
},
{
id: 'scrollbox',
+ category: 'navigation',
name: 'Scrollbox',
aka: ['horizontal scroller', 'scroll row', 'overflow row', 'scroll shadows', 'edge fade', 'horizontal scroll section', 'chip row'],
summary:
@@ -2763,6 +2854,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra
},
{
id: 'sticker',
+ category: 'conversion',
name: 'Sticker',
aka: ['sale badge', 'sale sticker', 'corner ribbon', 'product badge', 'discount badge', 'new badge', 'price tag', 'WooCommerce sale flash'],
summary:
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 5c398ca..259b320 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,6 +1,6 @@
---
import Base from '../layouts/Base.astro';
-import { catalog } from '../data/catalog';
+import { catalog, CATEGORIES } from '../data/catalog';
import { demos } from '../components/demos/index';
---
@@ -18,26 +18,40 @@ import { demos } from '../components/demos/index';
-
- {
- catalog.map((e) => {
- const Demo = demos[e.id];
- return (
-
- {Demo && }
-
-
- );
- })
- }
-
+
+
+ {
+ CATEGORIES.map((c) => (
+
+
+ {c.label}
+ {c.blurb}
+
+ {catalog
+ .filter((e) => e.category === c.id)
+ .map((e) => {
+ const Demo = demos[e.id];
+ return (
+
+ {Demo && }
+
+
+ );
+ })}
+
+ ))
+ }
How to ask for one
@@ -93,14 +107,45 @@ import { demos } from '../components/demos/index';
border-radius: 6px;
font-size: 0.9rem;
}
- .element h2 {
+ .jump ul {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ list-style: none;
+ padding: 0;
+ margin: 0 0 2.5rem;
+ }
+ .jump a {
+ display: inline-block;
+ padding: 0.35rem 0.8rem;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ color: var(--ink);
+ text-decoration: none;
+ }
+ .jump a:hover {
+ border-color: var(--purple-dark);
+ }
+ .gallery {
+ scroll-margin-top: 1rem;
+ }
+ .gallery__head h2 {
+ margin: 0 0 0.35rem;
+ }
+ .gallery__head p {
+ margin: 0;
+ max-width: 44rem;
+ color: var(--muted);
+ }
+ .element h3 {
+ font-size: 1.5rem;
margin: 0.4rem 0 0.6rem;
}
- .element h2 a {
+ .element h3 a {
color: var(--ink);
text-decoration: none;
}
- .element h2 a:hover {
+ .element h3 a:hover {
color: var(--purple-dark);
}
.how h2 {