diff --git a/public/demo/gallery-campus.svg b/public/demo/gallery-campus.svg new file mode 100644 index 0000000..cec6324 --- /dev/null +++ b/public/demo/gallery-campus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/demo/gallery-coast.svg b/public/demo/gallery-coast.svg new file mode 100644 index 0000000..a315d97 --- /dev/null +++ b/public/demo/gallery-coast.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/demo/gallery-pylons.svg b/public/demo/gallery-pylons.svg new file mode 100644 index 0000000..287ac3c --- /dev/null +++ b/public/demo/gallery-pylons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/demo/gallery-quarry.svg b/public/demo/gallery-quarry.svg new file mode 100644 index 0000000..0d3e520 --- /dev/null +++ b/public/demo/gallery-quarry.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/demo/gallery-ridge.svg b/public/demo/gallery-ridge.svg new file mode 100644 index 0000000..74de124 --- /dev/null +++ b/public/demo/gallery-ridge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/demo/gallery-roofs.svg b/public/demo/gallery-roofs.svg new file mode 100644 index 0000000..cc410cd --- /dev/null +++ b/public/demo/gallery-roofs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/demo/gallery-substation.svg b/public/demo/gallery-substation.svg new file mode 100644 index 0000000..c1d128f --- /dev/null +++ b/public/demo/gallery-substation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/demo/gallery-tower.svg b/public/demo/gallery-tower.svg new file mode 100644 index 0000000..0a3b7f7 --- /dev/null +++ b/public/demo/gallery-tower.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/demo/logo-bluepeak.svg b/public/demo/logo-bluepeak.svg new file mode 100644 index 0000000..a86f109 --- /dev/null +++ b/public/demo/logo-bluepeak.svg @@ -0,0 +1 @@ +Bluepeak \ No newline at end of file diff --git a/public/demo/logo-cedar.svg b/public/demo/logo-cedar.svg new file mode 100644 index 0000000..24b2a0d --- /dev/null +++ b/public/demo/logo-cedar.svg @@ -0,0 +1 @@ +Cedar & Co \ No newline at end of file diff --git a/public/demo/logo-harbor.svg b/public/demo/logo-harbor.svg new file mode 100644 index 0000000..3a1c3b0 --- /dev/null +++ b/public/demo/logo-harbor.svg @@ -0,0 +1 @@ +Harbor \ No newline at end of file diff --git a/public/demo/logo-northwind.svg b/public/demo/logo-northwind.svg new file mode 100644 index 0000000..b48e8c6 --- /dev/null +++ b/public/demo/logo-northwind.svg @@ -0,0 +1 @@ +Northwind \ No newline at end of file diff --git a/public/demo/logo-orbit.svg b/public/demo/logo-orbit.svg new file mode 100644 index 0000000..8c1081a --- /dev/null +++ b/public/demo/logo-orbit.svg @@ -0,0 +1 @@ +Orbit Labs \ No newline at end of file diff --git a/public/demo/logo-summit.svg b/public/demo/logo-summit.svg new file mode 100644 index 0000000..6bdb841 --- /dev/null +++ b/public/demo/logo-summit.svg @@ -0,0 +1 @@ +Summit Air \ No newline at end of file diff --git a/public/demo/slide-box-cards.json b/public/demo/slide-box-cards.json index 1f8ac00..97583bf 100644 --- a/public/demo/slide-box-cards.json +++ b/public/demo/slide-box-cards.json @@ -3,6 +3,8 @@ { "icon": "mug-hot", "title": "Coffee", "text": "Roasted on Tuesdays, sold by Friday.", "backText": "Two house blends and a single origin that changes each month. Ask at the counter for a taste of whatever is on the grinder.", "cta": { "text": "See this month’s beans", "href": "#slide-box-demo" }, "direction": "up" }, { "icon": "truck-fast", "title": "Delivery", "text": "Beans at your door in two days.", "backText": "Order by noon and it ships the same day, in a bag that keeps the beans fresh for a month.", "cta": { "text": "How delivery works", "href": "#slide-box-demo" }, "direction": "left" }, { "image": "promo.svg", "imageAlt": "", "title": "Wholesale", "text": "For cafés, offices and restaurants.", "backText": "Weekly deliveries, a grinder on loan and a morning of barista training for your staff.", "cta": { "text": "Ask for a price list", "href": "#slide-box-demo" }, "direction": "down" }, - { "icon": "calendar-check", "title": "Tasting mornings", "text": "Tap or click: this one ignores hover.", "backText": "The first Saturday of each month, 9 to 11. Six coffees, one table, no booking needed.", "cta": { "text": "Next dates", "href": "#slide-box-demo" }, "direction": "right", "trigger": "click" } + { "icon": "calendar-check", "title": "Tasting mornings", "text": "Tap or click: this one ignores hover.", "backText": "The first Saturday of each month, 9 to 11. Six coffees, one table, no booking needed.", "cta": { "text": "Next dates", "href": "#slide-box-demo" }, "direction": "right", "trigger": "click" }, + { "image": "gallery-ridge.svg", "imageAlt": "", "title": "Where it grows", "text": "The photo is the card.", "backText": "Our single origins come from three farms on the same ridge, picked by hand and dried on raised beds.", "cta": { "text": "Meet the farms", "href": "#slide-box-demo" }, "direction": "up", "variant": "image" }, + { "image": "gallery-coast.svg", "imageAlt": "", "title": "The roastery", "text": "A round card.", "backText": "Visit on Fridays and watch a batch go from green to brown.", "cta": { "text": "Plan a visit", "href": "#slide-box-demo" }, "direction": "up", "variant": "image", "shape": "circle" } ] } diff --git a/scripts/check-catalog.mjs b/scripts/check-catalog.mjs index d9cf5d3..4e423f2 100644 --- a/scripts/check-catalog.mjs +++ b/scripts/check-catalog.mjs @@ -154,6 +154,7 @@ const NEEDS_INPUT = [ 'flip-box', 'hotspot', 'info-circle', 'info-list', 'map', 'modal', 'news-ticker', 'responsive-table', 'slide-box', 'social-grid', 'tabcordion', 'tabs', 'testimonial-carousel', 'video-background', 'video-gallery', 'video-player', 'cart', 'product-list', 'product-page', + 'counter', 'logo-grid', 'photo-gallery', 'call-to-action', ]; const plainText = (v) => typeof v === 'string' && v.trim() === v && v.length > 0; for (const e of catalog) { diff --git a/scripts/check-categories.mjs b/scripts/check-categories.mjs index ceda33d..670bcb1 100644 --- a/scripts/check-categories.mjs +++ b/scripts/check-categories.mjs @@ -61,6 +61,7 @@ const GOLDEN = { 'business-hours': 'content', map: 'content', 'responsive-table': 'content', + counter: 'content', 'before-after': 'media', 'video-player': 'media', @@ -69,6 +70,8 @@ const GOLDEN = { 'card-slider': 'media', 'testimonial-carousel': 'media', icon: 'media', + 'logo-grid': 'media', + 'photo-gallery': 'media', 'image-effects': 'media', countdown: 'conversion', @@ -80,6 +83,7 @@ const GOLDEN = { cart: 'conversion', 'product-list': 'conversion', 'product-page': 'conversion', + 'call-to-action': 'conversion', notice: 'feedback', toast: 'feedback', @@ -92,6 +96,7 @@ const GOLDEN = { tags: 'controls', 'link-effects': 'controls', 'cookie-consent': 'controls', + separator: 'controls', }; const MIN_PER_CATEGORY = 3; diff --git a/src/components/demos/CallToActionDemo.astro b/src/components/demos/CallToActionDemo.astro new file mode 100644 index 0000000..8c1b8f2 --- /dev/null +++ b/src/components/demos/CallToActionDemo.astro @@ -0,0 +1,38 @@ +--- +/** + * Demo for `call-to-action`. The website renders this too (see README, "Consumed by + * superherotech.ai"), so every host token has a fallback. It uses no assets. + * + * Three bands: stacked and centred on the accent (UABB's Call to Action as latlonguas.com's + * home page has it), inline on dark with a phone link in the text (Beaver Builder's "cta" on + * its DC-FRZ page), and a light one with two buttons. Only the first is a region landmark. + */ +import CallToAction from '../../library/call-to-action/CallToAction.astro'; +--- +
+ + +

Read the questions people often ask below, or call us on (555) 555-0123.

+
+ +
+ diff --git a/src/components/demos/CounterDemo.astro b/src/components/demos/CounterDemo.astro new file mode 100644 index 0000000..7235db2 --- /dev/null +++ b/src/components/demos/CounterDemo.astro @@ -0,0 +1,61 @@ +--- +/** + * Demo for `counter`. The website renders this too (see README, "Consumed by + * superherotech.ai"), so every host token has a fallback. It uses no assets. + * + * Three rows: rings (the UABB "circle" look latlonguas.com uses), plain figures with a prefix + * and a suffix, and bars against a stated maximum. Each counts once as it scrolls into view; + * under reduced motion the final figures simply show. + */ +import Counter from '../../library/counter/Counter.astro'; +--- +
+
+

This year, so far

+ +
+
+

The workshop in numbers

+ +
+
+

Spring course places

+ +
+
+ diff --git a/src/components/demos/InfoListDemo.astro b/src/components/demos/InfoListDemo.astro index c6d2b87..2967bca 100644 --- a/src/components/demos/InfoListDemo.astro +++ b/src/components/demos/InfoListDemo.astro @@ -4,8 +4,9 @@ * superherotech.ai"), so every host token has a fallback. It uses no assets; the icons are * inline SVG. * - * Three lists: stacked with icons (two items linked), inline four-across numbered steps with - * the connector ("how it works"), and a stacked numbered timeline with the connector. + * Four lists: stacked with icons (two items linked), inline four-across numbered steps with + * the connector ("how it works"), a stacked numbered timeline with the connector, and a row of + * boxed, centred info boxes (UABB's and PowerPack's Info Box), one of them linked. */ import InfoList from '../../library/info-list/InfoList.astro'; @@ -48,6 +49,11 @@ const history = [ { meta: '2021', title: 'A team of six', text: 'Two crews, a designer and an office that answers the phone.' }, { meta: '2025', title: 'Water-wise gardens', text: 'Drip irrigation and drought-tolerant planting become a service of their own.' }, ]; +const boxes = [ + { title: 'Free site visit', text: 'We walk the garden with you and listen before we draw anything.', icon: services[0].icon }, + { title: 'Fixed written quote', text: 'One price, in writing, within two working days of the visit.', icon: services[1].icon, href: '#quote' }, + { title: 'Two-year guarantee', text: 'Any plant we put in that fails in its first two years, we replace.', icon: services[3].icon }, +]; ---
@@ -64,6 +70,11 @@ const history = [

Our story

+ +
+

Why people choose us

+ +
diff --git a/src/components/demos/PhotoGalleryDemo.astro b/src/components/demos/PhotoGalleryDemo.astro new file mode 100644 index 0000000..52f2c38 --- /dev/null +++ b/src/components/demos/PhotoGalleryDemo.astro @@ -0,0 +1,47 @@ +--- +/** + * Demo for `photo-gallery`. The website renders this too (see README, "Consumed by + * superherotech.ai"), so assets go through `asset()` and every host token has a fallback. + * + * Eight aerial-style illustrations in public/demo/gallery-*.svg, in the four categories + * latlonguas.com's gallery filters by. Press a photo for the lightbox: ← → or the buttons + * step through what the filter shows. + */ +import PhotoGallery from '../../library/photo-gallery/PhotoGallery.astro'; + +interface Props { + /** Where public/demo/ is served. The gallery uses /demo/; the website passes /elements-demo/. */ + assetBase?: string; +} +const { assetBase = '/demo/' } = Astro.props; +const asset = (name: string) => assetBase + name; +const p = (file: string, alt: string, category: string, caption?: string) => ({ + src: asset(file), + alt, + category, + caption, + width: 1200, + height: 800, +}); +const photos = [ + p('gallery-roofs.svg', 'Rooftops of a town centre from above, with the main road crossing it', 'Buildings', 'Roof survey, town centre'), + p('gallery-tower.svg', 'A telecom mast seen from directly above, its three guy lines spread out', 'Structures'), + p('gallery-pylons.svg', 'Power lines crossing green fields between three pylons', 'Power lines', 'Line inspection, after the storm'), + p('gallery-coast.svg', 'A sandy coastline with surf breaking along it', 'Scenic'), + p('gallery-campus.svg', 'A business park of low buildings and car parks', 'Buildings'), + p('gallery-quarry.svg', 'The terraced pit of a quarry, a haul road along its edge', 'Structures', 'Stockpile volumes, quarterly'), + p('gallery-substation.svg', 'Lines running into a rural substation', 'Power lines'), + p('gallery-ridge.svg', 'Layered mountain ridges under a pale sun', 'Scenic'), +]; +--- +
+ +
+ diff --git a/src/components/demos/SeparatorDemo.astro b/src/components/demos/SeparatorDemo.astro new file mode 100644 index 0000000..576eaba --- /dev/null +++ b/src/components/demos/SeparatorDemo.astro @@ -0,0 +1,42 @@ +--- +/** + * Demo for `separator`. The website renders this too (see README, "Consumed by + * superherotech.ai"), so every host token has a fallback. It uses no assets; the icon is + * inline SVG. + * + * Six separators: a plain line, a short accent bar that grows into view (the slide-in the old + * Beaver Builder pages used), dashed with a word, a heading between two lines, dotted with an + * icon, and a double line as a real thematic break. + */ +import Separator from '../../library/separator/Separator.astro'; + +const star = ``; +--- +
+

A plain line

+ +

A short accent bar, growing into view

+ +

Dashed, with a word

+ +

A heading between two lines

+ +

Dotted, with an icon, aligned to the start

+ +

Double, as a real thematic break

+ +
+ diff --git a/src/components/demos/SlideBoxDemo.astro b/src/components/demos/SlideBoxDemo.astro index 1e4451a..3e808f1 100644 --- a/src/components/demos/SlideBoxDemo.astro +++ b/src/components/demos/SlideBoxDemo.astro @@ -5,7 +5,9 @@ * * Four cards, one per `direction`: the back rises from the bottom (up, the default), comes in * from the right (left), drops from the top (down, on an image card) and comes in from the left - * (right, on a card that ignores hover). The cards live in public/demo/slide-box-cards.json, + * (right, on a card that ignores hover); then an image card, where the photo is the card + * (variant="image", UABB's Interactive Banner), and a round one (shape="circle", UABB's iHover). + * The cards live in public/demo/slide-box-cards.json, * which scripts/check-reveal-cards.mjs reads to pin the built page against. * * The icons are Font Awesome Free (CC BY 4.0), pasted with their attribution comment exactly as @@ -37,6 +39,8 @@ type Card = { cta: { text: string; href: string }; direction: 'up' | 'down' | 'left' | 'right'; trigger?: 'both' | 'click'; + variant?: 'card' | 'image'; + shape?: 'rect' | 'circle'; }; const cards = (fixture.cards as Card[]).map(({ icon, image, ...c }) => ({ ...c, diff --git a/src/components/demos/index.ts b/src/components/demos/index.ts index ae661e0..3ba1504 100644 --- a/src/components/demos/index.ts +++ b/src/components/demos/index.ts @@ -61,6 +61,11 @@ import StickerDemo from './StickerDemo.astro'; import CartDemo from './CartDemo.astro'; import ProductListDemo from './ProductListDemo.astro'; import ProductPageDemo from './ProductPageDemo.astro'; +import CounterDemo from './CounterDemo.astro'; +import LogoGridDemo from './LogoGridDemo.astro'; +import PhotoGalleryDemo from './PhotoGalleryDemo.astro'; +import SeparatorDemo from './SeparatorDemo.astro'; +import CallToActionDemo from './CallToActionDemo.astro'; import ImageEffectsDemo from './ImageEffectsDemo.astro'; // Astro components have no exported public type; this is the shape both callers need. @@ -114,5 +119,10 @@ export const demos: Record = { cart: CartDemo, 'product-list': ProductListDemo, 'product-page': ProductPageDemo, + counter: CounterDemo, + 'logo-grid': LogoGridDemo, + 'photo-gallery': PhotoGalleryDemo, + separator: SeparatorDemo, + 'call-to-action': CallToActionDemo, 'image-effects': ImageEffectsDemo, }; diff --git a/src/data/catalog.ts b/src/data/catalog.ts index f4a6ab5..e83525f 100644 --- a/src/data/catalog.ts +++ b/src/data/catalog.ts @@ -122,6 +122,13 @@ export interface Element { * licensed third-party material (so far: `icon`, on Font Awesome) carry one. */ license?: string; usedOn: { site: string; where: string }[]; + /** + * A live page that still runs the WordPress / page-builder module this element replaces, to + * look at before rebuilding one: `module` is the module as the page names it (the + * `fl-module-` class on a Beaver Builder page). These are clients' production sites: + * read them, never change anything there. + */ + reference?: { module: string; url: string; note?: string }[]; /** Path of the component in this repo — the file to copy into a client build. */ /** Where the file is really maintained, when this repo only holds a synced copy. */ source?: { repo: string; sync: string }; @@ -287,7 +294,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'], + aka: ['video hero', 'background video', 'Elementor video background', 'autoplay muted loop video', 'Beaver Builder video row', 'hero video', 'fl-bg-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.', pitch: 'Open your home page with moving footage of your work, without making visitors wait for it or wrestle with it.', // 320/mo, difficulty 21 (SE Ranking US, 2026-09-23). @@ -339,6 +346,9 @@ export const catalog: Element[] = [ `, usedOn: [{ site: 'superherotech.ai', where: '/elements/video-background/ (demo)' }], + reference: [ + { module: 'Beaver Builder row video (fl-bg-video)', url: 'https://latlonguas.com/', note: 'The hero on every page. The footage plays from Cloudflare Stream through video.latlonguas.com, the superhero-video Worker.' }, + ], file: 'src/library/video-background/VideoBackground.astro', added: '2026-09-23', }, @@ -754,7 +764,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'], + aka: ['PowerPack FAQ Module', 'PowerPack Advanced Accordions', 'UABB Advanced Accordion', 'Elementor Accordion', 'FAQ accordion', 'FAQ schema', 'toggle', 'collapsible', 'pp-advanced-accordion'], summary: 'Questions (or any titles) that open to show their answer, on native
/: keyboard, screen readers and find-in-page work with no script. One open at a time or several, numbered, chevron or plus, deep-linkable by #id. With jsonLd on, the same items become the page’s FAQPage structured data, so the FAQ and the accordion are one element.', pitch: 'FAQ page design that gets people their answer fast: each question opens with a tap, every answer is findable with Ctrl+F, and search engines and AI assistants can read them as questions and answers.', @@ -824,6 +834,9 @@ const faqs: AccordionItem[] = [ { site: 'jwalktours.com', where: '/faqs/ and the tour pages: its own FaqList.astro (
, FAQPage JSON-LD from the same array); moves to this element on its next FAQ request' }, { site: 'stbeautybar.com', where: '/about-us/ FAQ: its own Faqs.astro (exclusive, first open, rotating icon, FAQPage JSON-LD); moves to this element on its next FAQ request' }, ], + reference: [ + { module: 'pp-advanced-accordion', url: 'https://latlonguas.com/dc-frz/', note: 'The FRZ questions at the foot of the page.' }, + ], file: 'src/library/accordion/Accordion.astro', added: '2026-09-23', }, @@ -958,14 +971,14 @@ 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'], + aka: ['PowerPack Info List', 'UABB Info List', 'Elementor Icon List', 'icon list', 'steps list', 'process steps', 'vertical timeline', 'feature list', 'UABB Info Box', 'PowerPack Info Box', 'info box', 'info-box'], summary: 'Items with a marker (icon, image, number or dot), a title, a line of text and an optional link, stacked or inline in columns. With the connector the markers are joined by a line: a steps list or a vertical timeline. HTML and CSS only; there is no script.', pitch: 'Lay out your services, how you work step by step, or your story year by year, each with an icon or a number, in a list that reads on any screen.', // vertical timeline 500/mo, difficulty 21 (rising); icon list 660/45; timeline design website // 170/25 (SE Ranking US, 2026-09-23). search: { query: 'vertical timeline', alsoRanks: ['icon list', 'timeline design website'] }, - replaces: ['PowerPack “Info List” module (Beaver Builder)', 'UABB “Info List”', 'Elementor Icon List widget', 'hand-written icon lists'], + replaces: ['PowerPack “Info List” module (Beaver Builder)', 'UABB “Info List”', 'Elementor Icon List widget', 'hand-written icon lists', 'UABB and PowerPack “Info Box” modules (boxed and centred)'], goodFor: 'Services, “how it works”, process steps, milestones and feature lists.', notFor: 'Navigation (that is a menu) and long prose (write paragraphs).', asks: [ @@ -980,6 +993,8 @@ const faqs: AccordionItem[] = [ { name: 'markerPosition', type: '“left” | “top”', default: 'left for stack, top for inline', note: 'Top applies where inline has more than one column.' }, { name: 'headingLevel', type: '2 | 3 | 4 | 5', default: '3', note: 'Level of each title.' }, { name: 'size', type: '“sm” | “md” | “lg”', default: '“md”', note: 'Marker size: 2.25, 3 or 4rem. A dot is 1rem.' }, + { name: 'boxed', type: 'boolean', default: 'false', note: 'Each item a card: the UABB / PowerPack Info Box. With `href` the whole card is the link. No connector.' }, + { name: 'align', type: '“start” | “center”', default: '“start”', note: 'center: marker on top and everything centred, at every width (the usual info-box look).' }, { name: 'class', type: 'string', note: 'Class on the list, for the host to theme it.' }, ], theming: [ @@ -993,6 +1008,11 @@ const faqs: AccordionItem[] = [ { name: '--il-title / --il-text / --il-meta', fallback: 'inherit', note: 'Text colors.' }, { name: '--il-gap / --il-col-gap', fallback: '1.75rem / 1.5rem', note: 'Space between rows / columns. The connector spans the row gap.' }, { name: '--il-focus', fallback: 'currentColor', note: 'Focus ring around a linked item.' }, + { name: '--il-box-bg', fallback: '#fff', note: '`boxed`: card background.' }, + { name: '--il-box-border', fallback: '1px solid rgb(0 0 0 / 0.1)', note: '`boxed`: card border (a whole border shorthand).' }, + { name: '--il-box-radius', fallback: '14px', note: '`boxed`: card corners, and the linked card’s focus ring.' }, + { name: '--il-box-pad', fallback: '1.5rem', note: '`boxed`: inside each card.' }, + { name: '--il-box-shadow', fallback: 'none', note: '`boxed`: card box-shadow.' }, ], a11y: [ 'A real list:
    when the order matters (connector or numbers),
      otherwise, with role="list" so Safari keeps announcing it with list-style removed.', @@ -1014,6 +1034,9 @@ const faqs: AccordionItem[] = [ ({ meta: h.year, title: h.title, text: h.text }))} marker="dot" connector /> `, usedOn: [{ site: 'superherotech.ai', where: '/elements/info-list/ (demo)' }], + reference: [ + { module: 'info-box', url: 'https://latlonguas.com/', note: 'The two-up “From the strictly functional…” boxes: boxed, centred, no icon.' }, + ], file: 'src/library/info-list/InfoList.astro', added: '2026-09-23', }, @@ -1021,7 +1044,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'], + aka: ['PowerPack Video', 'UABB Video', 'Elementor Video', 'YouTube embed', 'Vimeo embed', 'video lightbox', 'lite YouTube embed', 'pp-video'], summary: 'A YouTube, Vimeo or self-hosted video behind your own poster and play button, in place or in a lightbox. Nothing is requested from YouTube or Vimeo until the button is pressed; then a privacy-enhanced iframe is created and focus moves into it.', pitch: 'Your video, your thumbnail, and nothing loads from YouTube until someone presses play.', @@ -1091,6 +1114,9 @@ const faqs: AccordionItem[] = [ /> `, usedOn: [{ site: 'superherotech.ai', where: '/elements/video-player/ (demo)' }], + reference: [ + { module: 'pp-video', url: 'https://latlonguas.com/services/', note: 'Two videos on the services page.' }, + ], file: 'src/library/video-player/VideoPlayer.astro', added: '2026-09-23', }, @@ -1211,7 +1237,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'], + aka: ['animated headline', 'Elementor Animated Headline', 'text rotator', 'word rotator', 'rotating text', 'typewriter effect', 'typing animation', 'Typed.js', 'fancy text', 'word reveal', 'highlighted headline', 'UABB Fancy Text', 'fancy-text'], summary: 'One line of text in one of six effects: build (word by word), typewriter, rotate (one slot turns over), distil (words fall away to a shorter phrase), highlight (a marker or underline sweeps), strike (a word is struck and replaced). Speed, cadence, hold, repeat, entrance and type are set per use. Nothing reflows, a screen reader hears the sentence once, and it rests whole under reduced motion or with no script.', pitch: 'Let your most important line arrive the way you would say it out loud, in your own type and at your own pace.', @@ -1293,6 +1319,9 @@ import AnimatedText from '../components/AnimatedText.astro'; { site: 'nicolelawton.com', where: 'Resources, the closing tagline "Cultivate Empower Connect": build, word by word, gap 600, once. A copy of this file; her phoenix between the words is drawn by the page, on .at__piece + .at__piece::before (pre-launch on nicolelawton.gohero.us)' }, { site: 'superherotech.ai', where: '/elements/animated-text/ (demo): all six effects' }, ], + reference: [ + { module: 'fancy-text', url: 'https://latlonguas.com/', note: '“Where above meets beyond!” under the hero; typed text on /services/ and /our-story/ too.' }, + ], file: 'src/library/animated-text/AnimatedText.astro', added: '2026-09-23', }, @@ -1736,7 +1765,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'], + aka: ['UABB Slide Box', 'slide box', 'reveal card', 'sliding card', 'hover reveal box', 'info box with slide-up detail', 'card overlay', 'UABB Interactive Banner', 'interactive-banner-1', 'UABB iHover', 'ihover', 'image hover effects'], summary: 'A card whose back panel slides in over the front from a chosen direction (up, down, left, right): on hover where there is a mouse, and on click, tap, Enter or Space everywhere, through a real button with aria-expanded. The front is a teaser (icon or image, title, a line), the back its detail (text and a link). Both panels share one height; the one out of view is inert.', pitch: 'A card that shows the headline first and slides up the detail when someone wants it, on a phone as well as with a mouse.', @@ -1744,7 +1773,7 @@ import { analytics } from '../data/site'; // hover effects 30/12; card overlay 50/10. "slide box" itself is 1,300/55, but its results // are microscope-slide storage boxes and toolbox drawer slides, not websites, so it stays an aka. search: { query: 'reveal card', alsoRanks: ['sliding card', 'card hover effects', 'card overlay'] }, - replaces: ['UABB “Slide Box” module (Beaver Builder), overlay style', 'hover-reveal info boxes in Elementor and Divi', 'hand-rolled CSS slide-up card overlays'], + replaces: ['UABB “Slide Box” module (Beaver Builder), overlay style', 'hover-reveal info boxes in Elementor and Divi', 'hand-rolled CSS slide-up card overlays', 'UABB “Interactive Banner 1 / 2” (variant="image")', 'UABB “iHover” (variant="image" shape="circle")'], goodFor: 'A grid of teasers whose detail is worth one more gesture: services that each need three lines of explanation and a link, job openings (title on the front, what the job is and Apply on the back), products with ingredients or specifications, team members with a short bio.', notFor: @@ -1766,6 +1795,8 @@ import { analytics } from '../data/site'; { name: 'trigger', type: "'both' | 'click'", default: "'both'", note: 'Whether hover ALSO slides it in. Click, tap, Enter and Space always do; a hover-only card would lock out touch screens and keyboards.' }, { name: 'duration', type: 'number (ms)', note: 'The slide. Overrides `--slb-duration` (450ms).' }, { name: 'headingLevel', type: '2 | 3 | 4 | 5 | 6', default: '3', note: 'Level of the headings; match the page outline.' }, + { name: 'variant', type: "'card' | 'image'", default: "'card'", note: 'image: the photo fills the card, the title sits on a scrim at its foot and the back is a tint that slides in over the photo (UABB Interactive Banner). Needs `image`.' }, + { name: 'shape', type: "'rect' | 'circle'", default: "'rect'", note: 'circle: a round card (UABB iHover), once the script runs; without it the panels stack as rounded boxes. Keep the back to about 20 words.' }, { name: 'label', type: 'string', note: 'Accessible name of the card’s button. Default: `title`, else the front’s first heading.' }, { name: 'class', type: 'string', note: 'Class on the root, for theming one card.' }, { name: 'slot front / slot back', type: 'slot', note: 'Any HTML for a panel; wins over the props for that panel. Keep links off the front: the whole front is the button.' }, @@ -1791,6 +1822,10 @@ import { analytics } from '../data/site'; { name: '--slb-easing', fallback: 'cubic-bezier(0.2, 0.7, 0.2, 1)', note: 'Timing of the slide.' }, { name: '--slb-fade', fallback: '200ms', note: 'The crossfade under reduced motion.' }, { name: '--slb-focus', fallback: 'var(--slb-accent)', note: 'Focus ring round the card.' }, + { name: '--slb-image-fg', fallback: '#fff', note: 'variant="image": text over the photo and on its tinted back.' }, + { name: '--slb-scrim', fallback: 'linear-gradient(to top, rgb(20 24 40 / 0.85), rgb(20 24 40 / 0.55) 60%, transparent)', note: 'variant="image": behind the front’s title, for 4.5:1 over the photo.' }, + { name: '--slb-image-back-bg', fallback: 'rgb(20 24 40 / 0.86)', note: 'variant="image": the tint that slides in over the photo.' }, + { name: '--slb-image-height', fallback: '16rem', note: 'variant="image": the card’s floor.' }, ], a11y: [ 'The card is a real + ) + } +
      +
      +
        + { + logos.map((l) => { + const img = {l.alt}; + return ( +
      • + {l.href ? ( + + {img} + + ) : ( + {img} + )} +
      • + ); + }) + } +
      +
      +
      + +{ + layout === 'marquee' && ( + + ) +} + + diff --git a/src/library/photo-gallery/PhotoGallery.astro b/src/library/photo-gallery/PhotoGallery.astro new file mode 100644 index 0000000..c880508 --- /dev/null +++ b/src/library/photo-gallery/PhotoGallery.astro @@ -0,0 +1,550 @@ +--- +/** + * PhotoGallery — a grid of photos with optional category filter chips; a click opens the photo + * large in one lightbox , with Previous / Next, the arrow keys, a "3 of 12" count and + * the caption. Replaces UABB's Photo Gallery (its filterable grid and lightbox), Beaver + * Builder's Gallery, PowerPack's Image Gallery, Elementor's Gallery, and the Envira, FooGallery, + * NextGEN, Modula and Magnific Popup / Fancybox lightboxes. + * + * Without JavaScript every tile is a link to the large photo, and every photo shows (no chips). + * The script keeps the links (Cmd/Ctrl-click and middle-click still open the file) and makes a + * plain click open the dialog instead. + * + * Filter chips (when photos carry a `category` and there are two or more): a named group of + * aria-pressed buttons, "All" first, with a polite status ("Scenic: 4 photos."). The lightbox + * steps only through the photos the filter shows. + * + * Lightbox: one native modal per gallery, showModal(): focus trapped, Escape closes, + * the page inert and not scrolling. It opens with focus on the close button; ← → (and the + * buttons) step, wrapping at the ends, and a polite status says "Photo 3 of 12" with the alt + * text. A press on the dark backdrop closes it; so does a swipe down on a touch screen. Focus + * returns to the tile. Reduced motion: no fade, no hover zoom. + * + * Images. `alt` is required on every photo (`""` only when the caption already says it all); + * width and height reserve the space; tiles are lazy. `full` is the large file for the + * lightbox (default: `src`). `aspect` crops the tiles to one shape (object-fit: cover) or keeps + * each photo's own (`natural`, a masonry-like column flow). + * + * Theming (each has a fallback): + * --pg-min / --pg-gap / --pg-radius tile minimum width, gap, corners (14rem / 0.75rem / 10px) + * --pg-tile-bg behind a loading tile (#e6e8ef) + * --pg-focus focus ring (#5933d8) + * --pg-chip-bg / --pg-chip-fg / --pg-chip-border (#fff / #1e283c / #c9cedb) + * --pg-chip-on-bg / --pg-chip-on-fg the pressed chip (#1e283c / #fff) + * --pg-backdrop / --pg-ui-bg / --pg-ui-fg lightbox backdrop and buttons (rgb(0 0 0 / 0.9) / rgb(255 255 255 / 0.15) / #fff) + * --pg-caption caption under a tile (inherit) + */ +export interface Photo { + src: string; + /** Required. Describe the photo; "" only when `caption` says everything. */ + alt: string; + width?: number; + height?: number; + /** The large file for the lightbox. Default `src`. */ + full?: string; + caption?: string; + /** One category, or several; the chips are made from these. */ + category?: string | string[]; +} +interface Props { + photos: Photo[]; + /** Tile shape: 'square', '4/3' (default), '3/2', '16/9', or 'natural' (each photo's own). */ + aspect?: 'square' | '4/3' | '3/2' | '16/9' | 'natural'; + /** Show the category chips. Default: when the photos carry two or more categories. */ + filter?: boolean; + allLabel?: string; + filterLabel?: string; + /** Captions under the tiles too (they always show in the lightbox). */ + captions?: boolean; + /** Open the lightbox on click. Off: tiles are plain links to the file. */ + lightbox?: boolean; + /** Names the gallery (its list and its dialog). */ + label?: string; + closeLabel?: string; + prevLabel?: string; + nextLabel?: string; + /** "{n} of {total}" in the dialog. */ + countLabel?: string; + class?: string; +} +const { + photos, + aspect = '4/3', + filter, + allLabel = 'All', + filterLabel = 'Filter photos', + captions = false, + lightbox = true, + label = 'Photo gallery', + closeLabel = 'Close', + prevLabel = 'Previous photo', + nextLabel = 'Next photo', + countLabel = 'Photo {n} of {total}', + class: className, +} = Astro.props; +if (!Array.isArray(photos) || photos.length === 0) throw new Error('PhotoGallery: `photos` is empty.'); +for (const [i, p] of photos.entries()) { + if (!p?.src) throw new Error(`PhotoGallery: photo #${i + 1} has no \`src\`.`); + if (typeof p.alt !== 'string') throw new Error(`PhotoGallery: photo #${i + 1} (${p.src}) needs \`alt\`: describe it, or "" when its caption says it all.`); + if (p.alt === '' && !p.caption) throw new Error(`PhotoGallery: photo #${i + 1} (${p.src}) has an empty \`alt\` and no caption, so nothing names it.`); +} +const ratios: Record = { square: '1', '4/3': '4 / 3', '3/2': '3 / 2', '16/9': '16 / 9' }; +if (aspect !== 'natural' && !ratios[aspect]) throw new Error(`PhotoGallery: \`aspect\` is square, 4/3, 3/2, 16/9 or natural; got "${aspect}".`); +const slug = (s: string) => s.toLowerCase().normalize('NFKD').replace(/[^\w]+/g, '-').replace(/^-|-$/g, '') || 'x'; +const catMap = new Map(); +const items = photos.map((p) => { + const cats = (Array.isArray(p.category) ? p.category : p.category ? [p.category] : []).map((c) => String(c).trim()).filter(Boolean); + for (const c of cats) if (!catMap.has(slug(c))) catMap.set(slug(c), c); + return { ...p, keys: cats.map(slug) }; +}); +const categories = [...catMap].map(([key, name]) => ({ key, name, n: items.filter((it) => it.keys.includes(key)).length })); +const showFilter = (filter ?? categories.length >= 2) && categories.length > 0; +const uid = 'pg-' + Math.random().toString(36).slice(2, 8); +--- + +
      + { + showFilter && ( + + ) + } + {showFilter &&

      } +

        + { + items.map((p) => ( +
      • +
        + + {p.alt + + {captions && p.caption &&
        {p.caption}
        } +
        +
      • + )) + } +
      + { + lightbox && ( + +
      +
      +
      + +
      + + +

      +

      + ) + } +
      + + + diff --git a/src/library/separator/Separator.astro b/src/library/separator/Separator.astro new file mode 100644 index 0000000..e23a053 --- /dev/null +++ b/src/library/separator/Separator.astro @@ -0,0 +1,253 @@ +--- +/** + * Separator — a line between sections, plain or with a short text or an icon in the middle: + * solid, dashed, dotted or double, full width or a short accent bar, aligned start, centre or + * end. Replaces UABB's Advanced Separator, Beaver Builder's Separator, Elementor's Divider and + * the
      styled by a theme. It is HTML and CSS; the only script is the optional `reveal`. + * + * Semantics. A line with nothing in it is decoration: `aria-hidden`, so a screen reader does + * not hear "separator" between every block (UABB renders empty divs; Elementor a bare
      ). + * Pass `semantic` for a real thematic break (`
      `), as between two parts of an article. With + * `text`, the text is real text and the lines are decoration; `as="h2"` makes it the heading + * of what follows ("Our services"), so the outline keeps it. An `icon` is decorative unless it + * has `iconLabel`. + * + * `reveal`. The line grows from the middle (or from its aligned edge) the first time it + * scrolls into view, as the old Beaver Builder "slide in" did. Only the script hides it, so + * without JavaScript, under prefers-reduced-motion (tracked live) and in print it is simply + * there. One script per page. + * + * Theming (each has a fallback): + * --sep-color line, icon and text colour (#c9cedb lines; text inherits) + * --sep-accent the text / icon colour (currentColor) + * --sep-thickness line thickness (1px; 3px for `width` shorter than 100%) + * --sep-gap space between line and text / icon (1rem) + * --sep-space margin above and below (2rem) + * --sep-icon-size icon size (1.5rem) + * --sep-duration reveal (700ms) + */ +interface Props { + /** Short text in the middle ("or", "Our services"). Real text. */ + text?: string; + /** Inline SVG markup in the middle. Decorative unless `iconLabel`. Ignored with `text`. */ + icon?: string; + iconLabel?: string; + /** The line's style. */ + line?: 'solid' | 'dashed' | 'dotted' | 'double'; + /** Any CSS width for the whole separator: '100%' (default), '6rem' for an accent bar. */ + width?: string; + align?: 'start' | 'center' | 'end'; + /** Element for the text: a heading keeps it in the outline. Default 'span'. */ + as?: 'span' | 'p' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + /** A plain line as a real thematic break (
      ), not decoration. */ + semantic?: boolean; + /** Grow into view once, the first time it scrolls into view. */ + reveal?: boolean; + class?: string; +} +const { + text, + icon, + iconLabel, + line = 'solid', + width = '100%', + align = 'center', + as: Tag = 'span', + semantic = false, + reveal = false, + class: className, +} = Astro.props; +if (!['solid', 'dashed', 'dotted', 'double'].includes(line)) throw new Error(`Separator: \`line\` is solid, dashed, dotted or double; got "${line}".`); +if (!['start', 'center', 'end'].includes(align)) throw new Error(`Separator: \`align\` is start, center or end; got "${align}".`); +const middle = text ? 'text' : icon ? 'icon' : null; +const bar = width !== '100%' && !middle; +const style = `--sep-w:${width}`; +const classes = ['sep', `sep--${line}`, `sep--${align}`, { 'sep--bar': bar, 'sep--mid': !!middle }, className]; +const locals = Astro.locals as Record; +const firstReveal = reveal && !locals.__separatorScript; +if (reveal) locals.__separatorScript = true; +--- + +{ + middle ? ( +
      +
      + ) : semantic ? ( +
      + ) : ( + + ) +} +{ + reveal && + (firstReveal ? ( + + ) : ( + + )) +} + + diff --git a/src/library/slide-box/SlideBox.astro b/src/library/slide-box/SlideBox.astro index 8195590..ee75018 100644 --- a/src/library/slide-box/SlideBox.astro +++ b/src/library/slide-box/SlideBox.astro @@ -22,6 +22,12 @@ * sliding; the panels crossfade. Without JavaScript the button is never shown and both panels * render, the back below the front, link included. Keep links off the front: it is the button. * + * Variants. `variant="card"` (default) puts the image, if any, across the top. `variant="image"` + * makes the photo the whole card, the title over a scrim at its foot, and the back a tint that + * slides in over the photo: UABB's Interactive Banner. `shape="circle"` makes the card a circle + * (with JavaScript; without it the panels stack as rounded boxes): UABB's iHover. Keep a + * circle's back to about 20 words; it clips what does not fit. + * * Sizing. Both panels share one grid cell, so the card is as tall as the taller panel and * neither is cut off; there is no fixed height. The host's grid sets the width. * @@ -34,6 +40,8 @@ * --slb-radius 14px --slb-padding 1.75rem 1.5rem --slb-shadow (box-shadow) --slb-min-height 14rem * --slb-title-size 1.2rem --slb-duration 450ms --slb-easing cubic-bezier(0.2, 0.7, 0.2, 1) * --slb-fade 200ms --slb-focus var(--slb-accent) + * --slb-image-fg #fff --slb-scrim (foot of an image card) --slb-image-back-bg rgb(20 24 40 / 0.86) + * --slb-image-height 16rem (an image card's floor) */ interface Props { title?: string; @@ -55,6 +63,10 @@ interface Props { /** ms. Overrides --slb-duration. */ duration?: number; headingLevel?: 2 | 3 | 4 | 5 | 6; + /** 'card' (default): image across the top. 'image': the photo is the card (Interactive Banner). */ + variant?: 'card' | 'image'; + /** 'circle': a round card, the iHover look. */ + shape?: 'rect' | 'circle'; /** Accessible name of the card's button. Default: `title`, else the front's first heading. */ label?: string; class?: string; @@ -72,6 +84,8 @@ const { trigger = 'both', duration, headingLevel = 3, + variant = 'card', + shape = 'rect', label, class: className, } = Astro.props; @@ -80,6 +94,9 @@ const who = `SlideBox${title ? ` "${title}"` : ''}`; if (image && typeof imageAlt !== 'string') { throw new Error(`${who}: \`image\` needs \`imageAlt\` — describe the image, or pass imageAlt="" if it is decorative on purpose.`); } +if (!['card', 'image'].includes(variant)) throw new Error(`${who}: \`variant\` is card or image; got "${variant}".`); +if (!['rect', 'circle'].includes(shape)) throw new Error(`${who}: \`shape\` is rect or circle; got "${shape}".`); +if (variant === 'image' && !image) throw new Error(`${who}: \`variant="image"\` needs an \`image\`; the photo is the card.`); if (!['up', 'down', 'left', 'right'].includes(direction)) throw new Error(`${who}: \`direction\` is up, down, left or right; got "${direction}".`); const uid = 'slb-' + Math.random().toString(36).slice(2, 8); @@ -93,7 +110,7 @@ locals.__slideBoxScript = true; ---
      )} + {e.reference && e.reference.length > 0 && ( + <> +

      Reference example

      +

      A live page that still runs the WordPress module this element replaces. It is a client’s site: look, change nothing.

      +
        + {e.reference.map((r) => ( +
      • + {r.module} on {r.url}{r.note && <>: {r.note}} +
      • + ))} +
      + + )} +

      Used on

        {e.usedOn.map((u) =>
      • {u.site}: {u.where}
      • )}