SIAG ES Digital Exhibit 2026
A React + Vite Project for building kiosk-style digital exhibits: a PWA-capable single-page app with schema-driven configuration (via .env and URL params), an idle-triggered screensaver, offline-cacheable media, and Matomo tracking.
Tech Stack
- React 19 + Vite
- react-router (
HashRouterby default,BrowserRouteravailable) - vite-plugin-pwa / Workbox for offline support
- SCSS, @ui-marcom design system
- Playwright for tests
- ESLint + Prettier + Stylelint
- JSDoc + clean-jsdoc-theme for generated code documentation
Getting Started
npm install
cp .env-sample .env # then adjust values, see "Configuration" below
npm run devThe app uses HashRouter, so URLs look like http://localhost:5173/#/. This matters for URL search params too - see the config section below.
If you switch to BrowserRouter (src/BrowserRouter.jsx, see App.jsx), VITE_BASE_URL is used both as Vite's base and as BrowserRouter's basename - it must be a path (e.g. / or /some-subpath/), not a full URL with protocol/host.
Available Scripts
| Script | Description |
|---|---|
npm run dev | Start the Vite dev server |
npm run build | Production build |
npm run preview | Preview a production build locally |
npm run lint | Run ESLint + Stylelint |
npm run lint:fix | Same, with autofix |
npm run test | Run Playwright tests |
npm run test:ui | Run Playwright tests in UI mode |
npm run docs | Generate the JSDoc site into docs/ (see below) |
npm run docs:serve | Serve the generated docs/ site on port 8080 |
npm run sbom | Generate a CycloneDX SBOM (sbom.xml) |
npm run validate:content | Validate public/data/*.json (schema + references + assets, see "Content") |
Configuration
Config values come from two sources - .env variables and URL search params - and are combined into a single, typed object via one hook: useConfig() (src/services/config/config.jsx).
Adding a new config value does not require writing new hook logic. Add one entry to CONFIG_SCHEMA in src/services/config/config-schema.js:
{
key: "screensaverTimeout", // property name on the returned config object
source: "param", // "env" (import.meta.env) or "param" (URL search param)
name: "screensaverTimeout", // the actual env var name (e.g. "VITE_...") or param name
type: "number", // "boolean" | "number" | "string"
default: 180,
min: 0, // optional, "number" type only
description: "Seconds of inactivity before the screensaver starts.",
}Values are validated/coerced generically by parseConfigValue() (src/utils/validationFunctions.js) - invalid or missing values fall back to default and log a warning.
The current schema (CONFIG_SCHEMA in config-schema.js) includes screensaverTimeout, screensaverFadeDuration, deviceType, defaultMode (selects which screensaver video plays, see "Screensaver" below) and contentFile.
Because the app uses HashRouter, URL params must come after the #:
http://localhost:5173/#/?screensaverTimeout=5&screensaverFadeDuration=300A param placed before the # is invisible to useConfig() and silently falls back to the default. Navigation preserves the params: slide navigation, the app selection and the menu's "Apps" button all carry the current query string along, so a kiosk keeps its configuration for its entire uptime.
Presentation Modes & Navigation
The app hosts 3 presentation modes/sales apps, defined once in src/services/presentationModes.js (PRESENTATION_MODES, { key, label, path, contentFile }):
value-partnership→/value-partnership(placeholder, no content yet)value-advisory-services→/value-advisory-servicesrad-enablement→/rad-enablement
/ always renders the app selection screen (src/pages/AppSelection/AppSelection.jsx): one tile per mode, and the target of the idle session reset (see "Screensaver"). The 3 app routes are also always reachable directly by URL. Routing lives in src/Routes.jsx.
The two content-driven apps share one page skeleton, SlidePage (src/components/SlidePage/SlidePage.jsx): it resolves the :viewId route param against the loaded content, renders the view's elements via ComponentRenderer, and mounts SlideNavigation. Each page (ValueAdvisoryServices.jsx, RadEnablement.jsx) only supplies its constants, CSS prefix and an optional header. SlidePage also handles the failure states: a visible message while content can't be fetched, and a redirect to the start view for unknown view ids.
SlideNavigation (src/components/SlideNavigation/SlideNavigation.jsx) is the fixed bottom bar with three modes: the default prev/next arrows plus menu button; an expanded menu bar (Home / Screensaver / Apps / Slides / close); and the slides overlay (src/components/SlidesOverlay/SlidesOverlay.jsx) - a full-screen grid of slide thumbnails grouped by story, with a story toggle switch, for jumping directly to any slide. Thumbnails come from the optional thumbnail field of a view in the content JSON (/assets/thumbs/...); views without one get a text placeholder tile.
Page/app switches use the native View Transitions API directly via navigateWithTransition (src/utils/viewTransition.js, document.startViewTransition + flushSync) for a light cross-fade (tuned in src/styles/index.scss). react-router's own viewTransition navigate option is not used here - it only works with the Data Router API (createHashRouter/RouterProvider), and this app uses the plain declarative <HashRouter>/<Routes> instead.
Screensaver
ScreensaverProvider (src/services/screensaver.jsx) combines idle detection (useIdle, src/hooks/idle.jsx) with the screensaverTimeout/screensaverFadeDuration config values and exposes isIdle plus a manual trigger (showNow, wired to the menu's "Screensaver" button) via useScreensaver().
Waking from idle also resets the kiosk session: while the screensaver is still fading out, the app navigates back to the app selection (/, config params preserved), so the next visitor starts fresh instead of inheriting the previous visitor's route, open menus and selections. The reset deliberately happens on wake-up rather than at idle start - the screensaver video is defined by the current route's content file, and navigating away at idle start would unload it mid-show.
The actual overlay is src/components/Screensaver/Screensaver.jsx - stays mounted at all times so the CSS opacity transition plays both when fading in and out (the video is paused and rewound while not idle, so it doesn't decode 24/7 behind the app). Its video depends on defaultMode: looked up from the current route's content file's screensavers object (see below), keyed by that mode's own key. With the default defaultMode "all" no entry matches, so no video is shown.
ScreensaverProvider and ContentProvider need router context (useConfig() uses useSearchParams), so they're mounted inside <HashRouter> in src/HashRouter.jsx, not in App.jsx.
Content
ContentProvider (src/services/content.jsx) fetches a JSON content file and exposes it via useContent(), so exhibit content can be edited without touching code. Which file it fetches depends on the current route: each entry in PRESENTATION_MODES (src/services/presentationModes.js) names the contentFile its route loads, so every presentation app has its own content file instead of one shared one (rad-enablement -> /data/rad-content.json, value-advisory-services -> /data/vas-content.json; value-partnership has none yet). Already-fetched files are cached in memory, so switching between apps doesn't refetch. The contentFile config value (URL param, no default) overrides the route-based choice, e.g. to test one file against any route.
Fetch failures are surfaced instead of silently blanking the kiosk: useContent() exposes an error, the pages render a visible message, and the provider keeps retrying every 10 s until it succeeds. Render errors anywhere below the routes are caught by an ErrorBoundary (src/components/ErrorBoundary.jsx) that shows a message and reloads the app after 15 s.
Content file structure
A content file has four top-level keys: header (persistent page header), screensavers, stories and views. A view (one slide) renders an array of typed content blocks; a story declares its slide order explicitly:
{
"stories": {
"cancer": {
"name": "Cancer Screening",
"header": { "icon": "/assets/images/..." },
"slides": ["cancer-1", "cancer-2", "cancer-3"]
}
},
"views": {
"cancer-2": {
"story": "cancer",
"thumbnail": "/assets/thumbs/...",
"elements": [
{ "type": "headline", "text": "What are your challenges?" },
{ "type": "buttonList", "buttons": [{ "id": "cancer-2-1", "label": "..." }] }
]
},
"cancer-2-1": {
"story": "cancer",
"parent": "cancer-2",
"elements": [ ... ]
}
}
}- Blocks render in array order; a block can repeat.
typeselects the element (see "Adding a new element" below); the optionalregionfield ("storyTitle" | "headline" | "content") overrides the element's default layout region. Headlines take an optionallevel(2 = slide headline, 3 = subheadline). - Navigation is derived, never authored: prev/next on root slides follow the story's
slidesarray, a sub view (parentfield) goes back to its parent and forward to the slide after it, and the first/last slide connect to the story hub. Standalone views outside stories ("rad-intro") chain vianextView. scripts/migrate-content.mjsconverts files from the legacy format (elements object, id-prefix conventions,nextViewchains) to this one.
Validation: npm run validate:content checks every content file against a JSON schema (skeleton in schemas/content.schema.json, per-element block schemas contributed by src/components/elements/*/schema.js) and verifies all view references (slides, story, parent, button/option targets) - both fail the run. Once public/assets is synced, it additionally warns (doesn't fail) about referenced asset files that don't exist yet - develop is a preview environment, so content still being produced in Drive must not block a deploy; a missing video/image just degrades visibly at runtime instead (stuck "Loading video...", broken image). CI runs the full check on every branch pipeline, and the develop image build re-runs the asset check right after the Google Drive sync.
Adding a new element
Each element folder under src/components/elements/ owns its whole contract, aggregated in elements/registry.js:
schema.js- blocktype, default layoutregionand the JSON-schemaproperties/requiredof the block (pure data, also used by the content validation)<Name>.jsx+<Name>.scss- the componentindex.js- the registry definition: schema +component+adapter(maps a raw content block to the component's props)
Register the definition in ELEMENT_REGISTRY (registry.js) and its schema in ELEMENT_SCHEMAS (schemas.js - node-importable, so it can't live in the registry), and the renderer, layout and validation all pick the new element up.
Each content file also carries its own screensavers entry (see above), keyed by that mode's own PRESENTATION_MODES key:
{
"screensavers": {
"rad-enablement": "/assets/videos/ra_screensaver.mp4"
}
}Asset paths in content files are authored root-absolute (/assets/...) and resolved against Vite's base once at load time (src/utils/resolveAssetPath.js), so subpath deployments (e.g. the test server) work without touching the JSONs.
Assets & Offline
Media assets live in Google Drive and are mirrored into public/assets (gitignored): locally via the Docker-based sync service in sync/ (one-shot or continuous watch mode), and in CI as part of the develop deployment (see "Deployment" below).
Runtime images are served as right-sized WebP. scripts/optimize-images.mjs (run with node scripts/optimize-images.mjs, uses sharp) converts new PNG slide thumbnails (to 270px WebP) and the large title backgrounds (max 2560px WebP), and moves the PNG masters to asset-src/ (gitignored, not served - keep them, public/assets is gitignored too, so the masters exist nowhere else). Content JSON references must point at the .webp files.
Offline support: the service worker (src/service-worker/service-worker.js, configured in vite.config.js via injectManifest.globPatterns) precaches the bundle, fonts, content JSONs and all assets at install time, so a kiosk keeps working through network loss even for slides nobody visited yet. On top of that, a StaleWhileRevalidate runtime route lets deployed assets/content update without a rebuild. Note the size cap (maximumFileSizeToCacheInBytes) is raised to cover the ~9.4 MB screensaver videos.
Kiosk hardening: pinch/double-tap zoom, text selection, long-press callouts, image dragging and overscroll are disabled globally (index.html viewport meta + src/styles/index.scss); the context menu is suppressed in production (App.jsx).
External Tabs / Windows
TabProvider/useTabs (src/services/tabs.jsx) opens external links in a separate tab/window, keyed by URL - opening the same URL twice focuses the existing tab instead of spawning a new one, and closeTab closes it again. Useful for links that should leave the kiosk view without navigating the app itself away from its route. (Currently has no consumer in the UI - kept for upcoming features, same as the hold-to-reload helper src/components/Reload.jsx.)
Tracking
TrackingProvider (src/services/tracking/tracking.jsx) integrates Matomo. Configure via .env:
VITE_MATOMO_SITE_ID- leave empty to disable tracking entirelyVITE_MATOMO_SESSION_TIMEOUT- inactivity seconds before a new session starts (0disables session timeouts)VITE_MATOMO_ENABLE_ROUTER_PAGE_TRACKING-"true"to automatically track page views on route change (GeneralPageTracking)
GeneralPageTracking also keeps document.title in sync with the current route (e.g. Rad Enablement - cancer-2), so pageviews are distinguishable in Matomo. Tip: keep VITE_MATOMO_SESSION_TIMEOUT at or above screensaverTimeout, otherwise Matomo counts a "new visit" while the previous visitor's session is still on screen.
Cacheable Video
<CacheableVideo> (src/components/CacheableVideo.jsx) is a drop-in replacement for <video src="...">. A plain <video> tag triggers HTTP range requests, which the service worker's Cache API can't store - this component fetches the file as a full response instead and hands the video element a local blob: URL, so it becomes cacheable/offline-capable.
Documentation (JSDoc)
Code is documented with standard JSDoc comments. Generate a browsable site:
npm run docs
npm run docs:serve # Pagefind (search) needs to be served over HTTP, not opened as a fileOutput goes to docs/ (gitignored, regenerate as needed). Add /** ... */ doc comments above exported functions/hooks to have them picked up - a plain /* ... */ comment is treated as file-level prose and ignored by the generator.
Always-on docs site: every push to develop also builds and deploys the current docs as a static site to https://<project-name>-docs.dev.clck.de (this project: https://digital-exhibit-2026-docs.dev.clck.de) - a standing bookmark that's always up to date with develop, independent of the app deploy. Pipeline: build_docs_develop (runs npm run docs, uploads docs/ as an artifact) -> build_docker_docs_develop (packages it via Dockerfile.docs, a minimal nginx image, into <registry>:docs-dev-<sha> / <registry>:docs-develop) -> deploy_develop starts it as the docs service in compose.develop.yml, routed by Traefik under its own subdomain (DOCS_HOST, override via the DOCS_HOST_OVERRIDE CI/CD variable) so it keeps working even if the app container is redeploying.
Testing
npm run test # headless
npm run test:ui # interactive UI modePlaywright (playwright.config.js) starts the Vite dev server itself (webServer, reused if one is already running) and runs against Chromium, Firefox and WebKit. tests/smoke.spec.js covers the app selection, a content-driven app's intro/navigation flow, story/sub-view branching and the unknown-view-id redirect - deliberately small, since content-authoring mistakes are caught much earlier by npm run validate:content (see "Content" above).
Linting
npm run lint # eslint + stylelint
npm run lint:fix # same, with autofixDeployment
Deployment to Develop Environment (dev.clck.de)
Every push to the develop branch deploys a full Docker-based preview to https://<project-name>.dev.clck.de (this project: https://digital-exhibit-2026.dev.clck.de).
How it works:
- Asset sync - the pipeline runs a one-shot rclone sync that mirrors the Google Drive export folder into
public/assets(same mechanism as the local sync service). This step is essential:public/assetsis gitignored, so without it the image would contain no media. The assets are baked into the image on purpose - the service worker precaches all assets with build-time revision hashes, so swapping files at runtime (e.g. via a volume) would never reach clients. New content requires a new build (see below). - Docker build & push - the image is built like the production one (
Dockerfile) and pushed to the GitLab registry asdev-<short-sha>anddevelop. - Deploy - the pipeline copies
compose.develop.ymlplus a generated.envto the dev server via SSH and runsdocker compose up -d. The server runs a Traefik reverse proxy with a wildcard DNS entry*.dev.clck.de: each project gets its own subdomain and an automatic Let's Encrypt certificate - no server configuration needed per project.
Updating content without a code change:
Since assets are baked in at build time, publish new Drive content by re-running the pipeline: Build → Pipelines → "New pipeline" on develop, or set up a recurring Pipeline schedule targeting develop.
Required CI/CD variables (Settings → CI/CD → Variables):
| Variable | Type | Description |
|---|---|---|
APPLICATION_DEV_SERVER | Variable | Hostname/IP of the dev server |
APPLICATION_DEV_USER | Variable | SSH user (optional, defaults to ubuntu) |
APPLICATION_DEV_SSH_PRIVATE_KEY | Variable | Private SSH key for the deploy user (unmasked - multiline; raw, base64 and File type all work) |
DOT_ENV_DEVELOP_FILE | File | Content of the .env used for the dev build |
DEV_HOST_OVERRIDE | Variable | Optional: full hostname if it should differ from <project-name>.dev.clck.de |
DOCS_HOST_OVERRIDE | Variable | Optional: full hostname for the docs site if it should differ from <project-name>-docs.dev.clck.de |
GDRIVE_TOKEN | Variable | rclone OAuth token - the full JSON object ({"access_token":...}), without surrounding quotes |
GDRIVE_FOLDER_NAME | Variable | Source folder path in Google Drive |
GDRIVE_TEAM_DRIVE_ID | Variable | Optional: Shared Drive ID |
Additionally a Deploy token named exactly gitlab-deploy-token with read_registry scope must exist (Settings → Repository → Deploy tokens) - the dev server uses it to pull images from the registry.
On the server (one-time setup): Docker + Compose, the external network proxy (docker network create proxy) and the Traefik stack. Each project lives in ~/apps/<project-name>/ (created by the pipeline); Traefik picks new containers up automatically via Docker labels defined in compose.develop.yml.
Deployment to Test Environment
Development builds are automatically deployed to a test environment upon merging a Merge Request into the test branch.
Each successful deployment creates a unique release accessible via a dedicated URL.
Accessing Your Development Build:
You can find your build at: https://test.click-solutions.com/clck/project-browser/ or https://test.click-solutions.com/<gitlab-group>/<project-name>/<pipeline-id>_<short-commit-sha>
<gitlab-group>/<project-name>: This path is derived from your project's path in GitLab.<pipeline-id>_<short-commit-sha>: This forms the unique release name, combining the pipeline ID and the short commit hash.- The pipeline itself prints out the url of your deployment, too
Customizing the Deployment Path:
You can override the default <gitlab-group>/<project-name> path by setting the CI/CD variable DEPLOY_PATH_OVERRIDE in your project's settings (e.g., siag/hpresenter_supertest). The URL will then use this custom path.
Handling Environment Variables:
If your application uses environment variables (e.g., via a .env file), define them as a CI/CD variable named DOT_ENV_TEST_FILE. This variable must be configured as a 'File' type, and its content should be the content of your .env file. The variable VITE_BASE_URL will be overwritten by the pipeline.
Release Management:
- The release name is automatically generated as
release_<pipeline-id>_<short-commit-sha>. - The test environment retains the last three successful builds.
- You can manually trigger a pipeline for any commit to redeploy a specific version.
Ready-to-use Docker images
Development builds automatically build a Docker image pushed to the GitLab registry when merged to main.
- The image name is automatically generated as
gitlab.clacks.de:5050/<group>/<repo-name>:<COMMIT_SHORT_SHA>. - The release image name is automatically generated as
gitlab.clacks.de:5050/<group>/<repo-name>:latest, e.g.gitlab.clacks.de:5050/siag/react-boilerplate-siag-exhibitions:latest - You can manually trigger a pipeline for any commit to redeploy a specific version.
Using images on-site:
- Define environment variables for local spin-up in
.env - Check
compose.ymland change the image in theappservice section - Login via
docker login gitlab.clacks.de:5050 -u gitlab-ext(find the "Access token" field in Keeper by searching gitlab-ext) - Run
docker-compose up -d appto start the app service, served via nginx on the configured port