Skip to content

Carousel unification: slot-only shared component, use-case dashboards, a11y fixes + site-wide astro:page-load cleanup#484

Open
rusikv wants to merge 18 commits into
thingsboard:mainfrom
rusikv:fix/restore-use-case-carousels
Open

Carousel unification: slot-only shared component, use-case dashboards, a11y fixes + site-wide astro:page-load cleanup#484
rusikv wants to merge 18 commits into
thingsboard:mainfrom
rusikv:fix/restore-use-case-carousels

Conversation

@rusikv

@rusikv rusikv commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Restores the use-case dashboard carousels lost in the content migration, then unifies every stepped (embla) carousel on the site onto a single content-agnostic shared Carousel component, fixes the accessibility/landing issues found along the way, and removes the dead astro:page-load listeners that were scattered site-wide.

What's included

Use-case dashboard carousels — restored the dashboard-screenshot carousels (and their images) on air-quality-monitoring, scada-energy-management, smart-metering, smart-retail, and water-metering.

Shared Carousel component (src/components/Carousel/)

  • Carousel.astro is a slot-only, content-agnostic engine — embla wiring + chrome (arrows, dots, header, autoplay) and nothing about slide content. Props: ariaLabel, loop, autoplay, autoplayInterval, layout: 'single' | 'multi', showArrows, arrowPlacement, title, showDots, slideCount (SSR dots), bleed, class.
  • CarouselSlide.astro (slot wrapper with SSR aria-label="N of M") and carousel-init.ts (lazy embla load via IntersectionObserver, prefers-reduced-motion gate, dot/arrow wiring, carousel:select event for future master-detail consumers).
  • Slide content lives with its consumers: Landing/DashboardSlide.astro (homepage text + image + overlay slide; owns the CarouselItem type) and UseCase/DashboardImageSlide.astro (use-case image + caption slide).
  • Migrated the homepage hero, use-case dashboards, the advantages strip, and the blog featured carousel onto it.
  • Deleted the UseCaseCarousel wrapper and the old Landing/Carousel.astro; AdvantagesCarousel shrank 356→190 lines; the blog dropped its own embla wiring.
  • Dropped the prop-driven items mode entirely — the component is slot-only, so there's no flat API folding two mutually-exclusive modes together.

Carousel behavior/polish

  • Replaced hand-rolled autoplay with the embla-carousel-autoplay plugin; respects reduced motion; stops on touch swipe but keeps desktop hover-resume.
  • Full ARIA carousel semantics (region/slide roles, dot aria-current); removed Math.random() ids.
  • Redesigned nav chrome with brand indigo accents.

Blog

  • Featured carousel migrated to the shared component (pill dots, SSR dots via slideCount, full-bleed on mobile via the bleed prop).
  • Replaced raw YouTube iframes broken by the site's referrer policy.

Marquee accessibility (logo strip, company hero photos, visualization widgets strip)

  • Added prefers-reduced-motion guards and aria-hidden on the duplicated copies (they previously animated unconditionally and announced content twice to screen readers).

Site-wide astro:page-load cleanup

  • The Astro View Transitions router is not enabled anywhere on this site (no <ClientRouter/>, no transition:* directives, no experimental flags; Starlight 0.39.2 doesn't inject it), so astro:page-load never fires. Every document.addEventListener('astro:page-load', …) registration was dead code.
  • Removed all 23 such registrations across 26 files and trimmed the now-inaccurate "fires on every view-transition" comments. Each component keeps its real init path (a DOMContentLoaded listener, an immediate init() call, or a readyState guard) and all idempotency guards, so behavior is unchanged today.

Intended behavior changes (call-outs for review)

  • Advantages strip: autoplay now suppressed under reduced motion; arrows adopt the shared indigo chrome; init is now lazy (IntersectionObserver).
  • Blog featured carousel: autoplay no longer dies permanently on first interaction (resumes on mouse-leave); card text is not selectable during drag, matching the other carousels.
  • astro:page-load removal: purely removes dead code today. Trade-off: if Starlight view transitions are ever enabled, these components would need re-init wired back (via a shared helper) to keep working across client-side navigations.

Out of scope / deliberate non-changes

  • Trainings/CoursesSection (a master-detail controller, not a plain slider) is kept separate; it can adopt carousel-init.ts + carousel:select later.
  • No shared Marquee primitive: the three marquees diverge too much and Astro renders a <slot/> only once (so a generic primitive can't produce the duplicated copy the seamless loop needs). Their accessibility was fixed in place instead.

Verification

  • astro check: 0 errors / 0 warnings (553 files). eslint: clean.
  • Zero astro:page-load references remain anywhere in src/.
  • Rendered output spot-checked on the dev server: homepage, the use-case pages, /blog/, the advantages strip, and all three marquees.

rusikv added 14 commits June 12, 2026 15:32
The old Jekyll site showed an image carousel on 13 use-case pages;
only 8 were migrated. Restore the missing 5 — smart-metering,
water-metering, smart-retail, air-quality-monitoring, and
scada-energy-management — by copying the original webp screenshots
and adding the overview carousel block to each data file.

Alt/title texts, image order, and dimensions match the legacy
carousel data verbatim, except scada-energy-management slide 3,
whose declared 1286x660 box was 1.59% off the real asset's aspect
ratio (2604x1358) and is corrected to the aspect-exact 1302x679.

The smart-retail and air-quality screenshots were exported
near-lossless on the legacy site; they are re-encoded at webp
quality 80 (matching their siblings), cutting those 10 files from
2.4 MB to 1.0 MB. The remaining 13 were already optimally encoded.
Replace the legacy owl-carousel-era chrome (thin hand-drawn SVG
chevrons, 72x4 dash pagination) on the shared Landing carousel:

- Arrows: 44px circular buttons with astro-icon tabler chevrons;
  lavender-tinted disc at rest, solid accent with contrast icon on
  hover, focus-visible ring.
- Dots: 8px round dots; the active one morphs into a 24px pill.
  Uniform 28x24px touch targets replace the mobile-only override.
- Colors ride on --carousel-accent: brand indigo in light theme,
  lavender in dark, via var(--color-brand-indigo,
  var(--color-product-cloud)) so it picks up the brand token once
  the brand unification lands while rendering identically today.
- New .carousel-stage wrapper anchors the arrow overlay to the
  slide area (excluding dots, and the caption in the simple
  variant) so buttons center on the image instead of sitting low.
- Home-page usecase variant: side padding 50px -> 64px to clear
  the round buttons.
The site serves referrer-policy: same-origin (Cloudflare managed
transform), so browsers send no Referer to YouTube's embed player,
which now requires one — raw iframes fail with player error 153.

Swap the two raw <iframe> embeds for the YouTubeVideo component,
whose referrerpolicy="strict-origin-when-cross-origin" attribute
overrides the document policy (plus nocookie domain, lazy loading,
and an accessible title). These were the only raw YT embeds left.
…t reduced motion

The custom setInterval autoplay could start a second timer without
clearing the first (drag a slide, then mouse out: pointerUp and
mouseleave each call startAutoplay), leaking an interval and advancing
slides at double speed. It also resumed autoplay on pointerUp even
while the cursor was still over the carousel, contradicting the
hover-pause behavior.

Swap it for the embla-carousel-autoplay plugin already used by
AdvantagesCarousel (delay from the existing autoplayInterval prop,
stopOnInteraction: false, stopOnMouseEnter: true), and skip autoplay
entirely when the user has prefers-reduced-motion set (WCAG 2.2.2).
Add the APG carousel pattern markup to the shared Landing carousel:
role=region with aria-roledescription=carousel and an aria-label
(new ariaLabel prop, set at both call sites), role=group slides
labelled 'N of M', and aria-current tracking on the active dot.

Also drop the Math.random() viewport id: the script finds the
viewport with a class selector inside its own wrapper, so the id
only made build output non-reproducible.
Move Landing/Carousel.astro to src/components/Carousel/ and generalize it
so other embla carousels can ride it:

- carousel-init.ts: client logic extracted from the inline script — lazy
  embla import, IntersectionObserver init, reduced-motion autoplay gate.
  Adds a carousel:select CustomEvent for master-detail consumers and
  JS-built dots (from scrollSnapList) for slot mode. Initial dot/button
  state is applied via direct calls since embla defers 'init' to a macrotask.
- Slot mode + CarouselSlide.astro for arbitrary slide markup; slides are
  sized through --carousel-slide-basis/--carousel-slide-gutter custom
  props because slot content is outside the component's style scope.
- perView={3} ladder (3/2/1 across lg/md) using loop-seam-safe
  margin-right gutters instead of flex gap.
- arrowPlacement='header' renders the nav buttons in a title row above
  the viewport instead of the floating overlay.
- CarouselItem href/title become optional (drops the '#' sentinel);
  homeCarousel.ts imports the interface instead of re-declaring it.
- ariaLabel is now a required prop.

Delete the UseCaseCarousel wrapper — use-cases/[slug].astro calls
Carousel directly with the same mapping (title || alt caption fallback
and section margins preserved).

Zero behavior change for the homepage and use-case page carousels.
AdvantagesCarousel keeps only the card markup (in CarouselSlide slots)
and delegates all carousel behavior to Carousel with
arrowPlacement='header', perView={3}, autoplay.

Intentional changes vs the standalone implementation:
- autoplay is now suppressed under prefers-reduced-motion
- nav arrows adopt the shared indigo .carousel-button chrome
- gains role=region/slide ARIA semantics and lazy IO init
- drops the Math.random() viewport ids and the duplicated embla script
Replace the blog's hand-rolled embla wiring with the shared
Carousel component in slot mode. The editorial card markup and the
bordered pill-dots chrome stay page-local; only the engine moves.

- Add a dotCount prop to Carousel so slot-mode dots render at SSR
  (no layout shift waiting for JS) instead of being built after init.
- Card slides keep their full-viewport flush layout: gap is zeroed on
  the container directly, since the wrapper self-declares --carousel-gap
  and a parent-level custom-prop override would be shadowed.
- Slide 0 keeps loading=eager/fetchpriority=high for LCP.
- Drop the page's static embla import + initCarousel; the shared
  component lazy-loads embla via IntersectionObserver.

Intended behavior changes vs the old implementation:
- autoplay no longer dies permanently on first interaction
  (stopOnInteraction false + stopOnMouseEnter) and is suppressed
  under prefers-reduced-motion
- card text is no longer selectable during drag (user-select: none),
  matching every other carousel on the site
On phones the featured card was inset by the blog container gutter on
both sides, which looked cramped. Break the carousel section out of the
container's mobile padding (var(--spacing-4); the container also drops
its border below 1024px) so the card spans edge-to-edge, and drop the
card's rounded corners + side borders so the flush edge reads as
intentional. Negative margins rather than 100vw/transform, to avoid
clashing with the transform-based section entrance animation.
The 'default' items-variant had no consumers (homepage uses 'usecase',
use-cases use 'simple', blog/advantages use slot mode) and rendered
markup identical to 'simple'. Collapse it: remove the variant value and
its dead render branch + CSS, and stop stamping a meaningless
carousel-<variant> class on slot-mode wrappers (they get carousel-slotted).
Also clarify the dotCount doc comment.
The three CSS marquees (logo strip, company hero photos, visualization
widgets strip) each duplicate their content for a seamless loop but
animated unconditionally and announced the duplicate copy to screen
readers. For each: add a prefers-reduced-motion guard that holds the
strip still, and aria-hidden the duplicated half (the visualization
duplicate also gets alt=""). HeroPhotoCarousel's drag handler no longer
re-arms the auto-scroll under reduced motion.
Review follow-ups:
- Autoplay used stopOnInteraction:false + stopOnMouseEnter:true, but
  mouseenter never fires on touch, so a swipe didn't pause autoplay and
  it kept yanking the slide away. Gate stopOnInteraction on
  (pointer: coarse): touch stops on swipe, mouse keeps hover-pause +
  resume-after-drag.
- Move the data-carousel-init check inside the IntersectionObserver
  callback so a second observeCarousels() run (e.g. astro:page-load +
  DOMContentLoaded) can't double-init the same wrapper.
CarouselSlide now takes index/total and renders the "N of M" aria-label
server-side, matching items-mode slides. carousel-init.ts keeps setting
it as a JS fallback only when the props are omitted. Blog featured and
advantages carousels pass index/total so their slides are named without
relying on JS.
The simple-variant carousel renders title as a visible caption, so the
placeholder alt text ("water metering 1"…) was showing on-screen. Give
each water-metering and SCADA energy-management dashboard image a real
title (and descriptive alt for the water-metering placeholders),
written from the actual dashboard contents.
@rusikv rusikv requested a review from vvlladd28 June 15, 2026 13:00

@vvlladd28 vvlladd28 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Reviewed 43 changed files in Carousel unification: shared component + use-case dashboards, blog & marquee a11y fixes. Left 6 comment(s) inline.

This is a clean, well-documented refactor. On the correctness side I found no hard bugs: image paths resolve through SmartImage's /src/assets/images/** glob, there are no orphaned references to the deleted Landing/Carousel.astro or UseCaseCarousel.astro, every Carousel consumer passes the required ariaLabel, the double-init race is explicitly guarded, and the marquee aria-hidden correctly targets only the duplicated copies. The inline comments are all quality/design observations — mostly about the shape of the new component's public API.


This review was auto-generated. Findings may contain errors — please verify before applying changes.

Comment thread src/components/Carousel/Carousel.astro Outdated
autoplayInterval?: number;
loop?: boolean;
/** 3 shows three slides per view on desktop, two below lg, one below md. */
perView?: 1 | 3;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perView as a 1 | 3 union reads awkwardly. The name suggests "slides shown per view", but 3 actually selects a fixed 3/2/1 responsive ladder and 2 is impossible — so the number doesn't map to a single observable behavior, and a future layout means another magic literal. A named mode like layout: 'single' | 'multi' would communicate intent better and be harder to misuse.

Comment thread src/components/Carousel/Carousel.astro Outdated
/** Prop-driven slides. Omit and fill the default slot with `<CarouselSlide>`s instead for arbitrary markup. */
items?: CarouselItem[];
/** Items-mode slide design (ignored in slot mode). The usecase variant expects `href` on every item. */
variant?: 'usecase' | 'simple';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Props surface folds two mutually-exclusive modes (items-mode vs slot-mode) into one flat interface: variant is ignored in slot mode, dotCount is ignored in items mode, arrowPlacement='header' only matters with a title, and title only matters under header placement. The JSDoc helps, but it's easy to pass a prop that silently does nothing. Now that every consumer except the home page uses slot mode, it's worth asking whether items-mode is still earning its keep — turning the home usecase slide into its own small slide component and going slot-only would shrink this API considerably.

Comment thread src/components/Carousel/Carousel.astro Outdated
</div>
) : (
<div class="carousel-item-simple">
{item.href ? (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The simple-variant SmartImage is duplicated verbatim across the href / no-href branches (same five props). Rendering the image once and conditionally wrapping just the anchor around it would avoid keeping two identical copies in sync.

<script>
import { observeCarousels } from './carousel-init';

document.addEventListener('DOMContentLoaded', observeCarousels);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registering observeCarousels on both DOMContentLoaded and astro:page-load means a hard load fires both, and each call builds a fresh IntersectionObserver over the not-yet-initialized wrappers — so you can end up with two live observers watching the same set (carousel-init.ts's flag still prevents double init, as its comment notes, but not the redundant observer). Since the site uses the view-transitions router, astro:page-load fires on the initial load too, so dropping the DOMContentLoaded listener would remove the duplication.

Comment thread src/pages/use-cases/[slug].astro Outdated
<UseCaseCarousel images={data.overview.carouselImages} />
<section class="uc-carousel-section">
<Carousel
items={data.overview.carouselImages.map((img) => ({ ...img, title: img.title || img.alt }))}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Falling back to img.alt for the caption means that whenever a carousel image omits title, its long screen-reader alt-text (e.g. "ThingsBoard dashboard showing energy and water consumption, district map, and critical alarm for District B…") gets rendered as a visible caption under the slide. Alt text and a human-facing caption have different audiences and lengths. Since all the use-case images in this PR do supply a title, consider only setting title when one is authored (and letting the simple variant render no caption otherwise) rather than surfacing alt prose.

draft: false
---

import YouTubeVideo from '~/components/YouTubeVideo.astro';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new import uses the legacy ~/ alias, but CLAUDE.md asks for the @components/* alias on new code — @components/YouTubeVideo.astro here (same import was added to the calculated-fields post). Minor, but these lines are net-new in this PR so it's a cheap spot to follow the convention.

rusikv added 4 commits June 16, 2026 11:34
…fallback

Review follow-up (thingsboard#484): the SCADA oil-&-gas drilling carousel images had
no title, so the simple variant fell back to rendering their screen-reader
alt text as a visible caption. Give the three images real captions (written
from the dashboards), and pass carouselImages straight through instead of
`title: img.title || img.alt` — CarouselImage already satisfies CarouselItem,
so a title-less image now shows no caption rather than leaking alt prose.
`perView: 1 | 3` read awkwardly (3 selected a 3/2/1 responsive ladder,
2 was impossible). `layout: 'single' | 'multi'` maps to one observable
behavior each. Pure rename — no behavior change.
Strip all slide-content knowledge out of Carousel.astro so it is a pure
embla engine + chrome (arrows, dots, header, autoplay, layout). The
component no longer ships an items/variant API or any slide CSS.

- Extract the homepage text+image+overlay slide into Landing/DashboardSlide
  (now owns the CarouselItem type).
- Extract the use-case image+caption slide into UseCase/DashboardImageSlide.
- Drop the items-mode render block and the CarouselItem export.
- Rename dotCount -> slideCount (SSR dot count).
- Replace the implicit carousel-slotted bleed with an explicit `bleed` prop.

Blog and advantages carousels were already slot-based; they opt into
`bleed` for their bordered cards. No visual change on any surface
(check/lint/dev-parity/adversarial parity review all clean).
The Astro View Transitions router is not enabled anywhere on this site
(no <ClientRouter/>, no transition:* directives, no experimental flags;
Starlight 0.39.2 does not inject it), so astro:page-load never fires.
Every document.addEventListener('astro:page-load', ...) registration was
therefore dead code.

Remove all 23 registrations across 26 files and trim the now-inaccurate
'fires on every view-transition' comments. Each component keeps its real
init path (DOMContentLoaded listener, immediate init() call, or readyState
guard) and all idempotency guards, so behavior is unchanged today.

Verified: astro check 0 errors/warnings, eslint clean, zero astro:page-load
references remain.
@rusikv rusikv changed the title Carousel unification: shared component + use-case dashboards, blog & marquee a11y fixes Carousel unification: slot-only shared component, use-case dashboards, a11y fixes + site-wide astro:page-load cleanup Jun 16, 2026
@rusikv rusikv requested a review from vvlladd28 June 16, 2026 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants