Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
711620f
6871: Started cleanup of template code
tuj Apr 12, 2026
eba8b13
6871: Finished image-text cleanup
tuj Apr 12, 2026
d4eba0e
6871: Fixed BaseSlideExecution instantiation issue
tuj Apr 12, 2026
6d71948
6871: Changed to use hook for BaseSlideExecution
tuj Apr 12, 2026
8f403a5
6871: Added test for useBaseSlideExecution
tuj Apr 12, 2026
15e83c1
6871: Introduced hook for slide execution where multiple entries are …
tuj Apr 12, 2026
97d0f44
6871: Changed to use useMultipleEntrySlideExecution
tuj Apr 12, 2026
c09d8ca
6871: Transition to use hook
tuj Apr 12, 2026
771aeee
6871: Fixed slideshow animation reset issues
tuj Apr 12, 2026
65c5a91
6871: Fixed fixture
tuj Apr 12, 2026
47b2c0d
Merge branch 'release/3.0.0' into feature/template-cleanups
tuj Apr 22, 2026
d5986da
7258: Fixed paths where slideDown might not be called
tuj Apr 22, 2026
f53ab04
7258: Fixed video paths where slideDone might not be called
tuj Apr 22, 2026
fa293af
7258: Fixed fixture
tuj Apr 22, 2026
0658a2c
7258: Only apply fade where there are more entries to transition to
tuj Apr 23, 2026
12f425c
7258: Moved base-slide-execution into hook instead of as a separate file
tuj Apr 23, 2026
522d026
7258: Applied coding standards
tuj Apr 23, 2026
c46bf78
Merge branch 'release/3.0.0' into feature/template-cleanups
tuj Aug 28, 2026
d8776a7
fix: fixed merge issue
tuj Aug 28, 2026
08da581
fix: address code review on the slide execution hooks
tuj Aug 28, 2026
1e35756
fix: install the video duration guard from durationchange too
tuj Aug 28, 2026
f59af13
docs: correct the BaseSlideExecution upgrade note
tuj Aug 28, 2026
1b76eed
Merge branch 'release/3.0.0' into feature/template-cleanups
tuj Aug 31, 2026
f4085fb
test: cover run changing between two truthy values in the slide execu…
tuj Aug 31, 2026
5e9c647
fix: address review findings on the slide-execution hooks
turegjorup Aug 31, 2026
59d23b4
Merge pull request #11 from itk-dev/fix/template-hook-review-followups
tuj Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .claude/agents/slide-template-reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Every template `.jsx` must:
export default { id, config, renderSlide };
```

2. **Call `slideDone()`** at some point — either via `BaseSlideExecution` (which calls it after `slide.content.duration` seconds, see `assets/shared/slide-utils/base-slide-execution.js`) or directly from a `useEffect` / event handler. **A template that never calls slideDone() locks the playlist.**
2. **Call `slideDone()`** at some point — either via `useBaseSlideExecution` (which calls it after `slide.content.duration` ms, see `assets/shared/slide-utils/useBaseSlideExecution.js`), via `useMultipleEntrySlideExecution` (which cycles `entries` then calls it, see `assets/shared/slide-utils/useMultipleEntrySlideExecution.js`), or directly from a `useEffect` / event handler. **A template that never calls slideDone() locks the playlist.**

3. Read content from `slide.content` using keys that exist in the `.json`'s `adminForm` `name:` values. Mismatched keys = the admin form writes data the renderer never reads.

Expand All @@ -47,7 +47,9 @@ Every template `.json` must:

3. **Re-export shape** — `.jsx` exports `default { id, config, renderSlide }`. Missing any of the three = the template won't register.

4. **slideDone signalling** — the rendered component either uses `BaseSlideExecution` (preferred, lives in `assets/shared/slide-utils/base-slide-execution.js`) or calls `slideDone()` directly. Search for `slideDone` in the `.jsx`; if it's only in the prop signature and never called, that's a blocker.
4. **slideDone signalling** — the rendered component either uses one of the hooks in `assets/shared/slide-utils/` (`useBaseSlideExecution` for a fixed duration, `useMultipleEntrySlideExecution` to cycle entries first — both preferred) or calls `slideDone()` directly. Search for `slideDone` in the `.jsx`; if it's only in the prop signature and never called, that's a blocker.

When the template reads `entryIndex` from `useMultipleEntrySlideExecution`, check it guards on `null` ("not started") rather than treating the initial value as index `0` — timers or counters anchored to mount instead of to `run` are the recurring bug here.

5. **adminForm / renderer key alignment** — for each `name:` in `adminForm`, confirm it's read by the renderer (`slide.content.<name>` or destructured from `content`). For each key the renderer reads from `slide.content`, confirm there's a matching `adminForm` entry — otherwise the admin can't set it. Mismatches both ways are bugs.

Expand Down
85 changes: 60 additions & 25 deletions .claude/skills/add-slide-template/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,24 @@ A slide template is the visual layout shown on a Screen. Each template is a two-

Two locations, same shape:

- `assets/shared/templates/<name>.{jsx,json}` — shipped with the project. PRs that add templates here are contributions to upstream.
- `assets/shared/custom-templates/<name>.{jsx,json}` — installation-specific templates. This folder is **gitignored** (see `.gitignore`); populate it via a fork, symlink, or per-deployment repo.
- `assets/shared/templates/<template-name>.{jsx,json}` — shipped with the project. PRs that add templates here are contributions to upstream.
- `assets/shared/custom-templates/<template-name>.{jsx,json}` — installation-specific templates. This folder is **gitignored** (see `.gitignore`); populate it via a fork, symlink, or per-deployment repo.

If the template is general-purpose, put it in `templates/` and consider a contribution PR (see README "Contributing template"). If it's specific to your tenant's needs, put it in `custom-templates/`.

## Files to create

### `<name>.json` — config + admin form schema
### `<template-name>.json` — config + admin form schema

```json
{
"title": "Display name (Danish — match existing templates' tone)",
"id": "<ULID>",
"options": {},
"adminForm": [
{ "key": "<name>-form-1", "input": "header", "text": "Skabelon: <Title>", "name": "header1", "formGroupClasses": "h4 mb-3" },
{ "key": "<name>-form-2", "input": "textarea", "name": "title", "label": "Overskrift", "formGroupClasses": "col-md-6" },
{ "key": "<name>-form-3", "input": "duration", "name": "duration", "min": "1", "type": "number", "label": "Varighed (i sekunder)", "required": true, "formGroupClasses": "col-md-6 mb-3" }
{ "key": "<template-name>-form-1", "input": "header", "text": "Skabelon: <Title>", "name": "header1", "formGroupClasses": "h4 mb-3" },
{ "key": "<template-name>-form-2", "input": "textarea", "name": "title", "label": "Overskrift", "formGroupClasses": "col-md-6" },
{ "key": "<template-name>-form-3", "input": "duration", "name": "duration", "min": "1", "type": "number", "label": "Varighed (i sekunder)", "required": true, "formGroupClasses": "col-md-6 mb-3" }
]
}
```
Expand All @@ -53,20 +53,23 @@ Or any online ULID generator — just paste the result into `id`.
| `select` | Dropdown; needs `options: [{key, title, value}]` |
| `checkbox` | Boolean toggle |
| `image` / `video` / `file` | Media picker (set `multipleImages: true` for image arrays) |
| `duration` | Slide duration field |
| `duration` | Slide duration field — see units note below |
| `contacts` | Contact entries |
| `feed` | Bind a feed to the slide (see "Feed integration" below) |
| `table` | Editable table |

Every `adminForm` entry needs a unique `key:` (scoped to the template is fine) and a `name:`. The `name:` is the field on `slide.content` the renderer reads.

### `<name>.jsx` — the renderer + contract
**Exception — the `duration` input must be named `duration`.** The Admin's duration widget hardcodes its write target (`content-form.jsx` writes to `id: "duration"` regardless of `name:`), so any other name silently stores nothing where the renderer looks.

**Duration units.** The Admin's `duration` field shows **seconds** in the UI but stores **milliseconds** in `slide.content.duration` (×1000 on write, ÷1000 on display). The renderer therefore reads ms — that's why the jsx below defaults to `15000` while the form label says "i sekunder".

### `<template-name>.jsx` — the renderer + contract

```jsx
import { useEffect } from "react";
import BaseSlideExecution from "../slide-utils/base-slide-execution.js";
import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js";
import "../slide-utils/global-styles.css";
import myTemplateConfig from "./<name>.json";
import myTemplateConfig from "./<template-name>.json";

function id() {
return myTemplateConfig.id;
Expand All @@ -89,26 +92,53 @@ function renderSlide(slide, run, slideDone) {
}

function MyTemplate({ slide, run, slideDone, content, executionId }) {
// BaseSlideExecution calls slideDone() after `content.duration` seconds.
const { title, duration = 15000 } = content; // ms — the Admin stores ms

// Calls slideDone(slide) once `duration` ms have passed since `run` became
// truthy. A *new* truthy `run` value restarts the timer without a remount —
// that's how a single-slide region replays the slide. The timer is cleared
// on unmount, and an invalid or missing duration falls back to 15000 ms.
// For anything more complex (video end, user interaction), invoke
// slideDone() yourself.
useEffect(() => {
if (!run) return;
const exec = new BaseSlideExecution(slide, slideDone);
exec.start();
return () => exec.stop();
}, [run, slide, slideDone]);

const { title } = content;
useBaseSlideExecution({ slide, run, slideDone, duration });

return <div className="my-template">{title}</div>;
}

export default { id, config, renderSlide };
```

**Critical: `slideDone()` must be called.** A template that never signals done locks the playlist on whichever screen it loads on. `BaseSlideExecution` is the standard way for fixed-duration slides; for video-driven or interactive slides, call `slideDone()` from the relevant event handler.
**Critical: `slideDone()` must be called.** A template that never signals done locks the playlist on whichever screen it loads on. `useBaseSlideExecution` is the standard way for fixed-duration slides. For video-driven or interactive slides, call `slideDone()` yourself from the relevant event handler — and make sure **every** path reaches it exactly once: see `templates/video.jsx` for the canonical guard pattern (`ended`/`error` listeners plus a metadata timeout plus a duration-based backstop, all funnelled through an idempotent `finish()`).

#### Templates that cycle through entries

If the template steps through a list (feed entries, images) before signalling done, use
`useMultipleEntrySlideExecution` instead — it owns the cycling and calls `slideDone()` after the last
entry:

```jsx
const { currentEntry, entryIndex } = useMultipleEntrySlideExecution({
entries,
run,
slide,
slideDone,
entryDuration, // ms per entry — see units note below
});
```

**`entryDuration` is in milliseconds.** Feed configurations typically store **seconds** — convert at
the template boundary, as every in-tree consumer does (`entryDuration * 1000` in `rss.jsx` and
`news-feed.jsx`). Passing raw seconds is not caught by the fallback: `10` is a valid positive
number, so each entry displays for 10 ms and the slide flashes past. Only invalid values (missing,
zero, negative, non-numeric) fall back to 15000 ms.

`currentEntry` and `entryIndex` are both `null` until the slide starts running, so guard on that
rather than assuming index `0` — anchoring your own timers or counters to mount instead of to `run`
is the classic bug here. See `rss.jsx`, `slideshow.jsx`, `news-feed.jsx`, `instagram-feed.jsx` and
`poster.jsx` for the five in-tree usages. The hook is a no-op on an empty `entries` array; templates
add their own short fallback timer for that case.

### Optional: `<name>/<name>.scss`
### Optional: `<template-name>/<template-name>.scss`

Component-scoped styles go in a sibling subfolder (see `image-text/image-text.scss` for the canonical example). Import it from the `.jsx`.

Expand All @@ -119,6 +149,7 @@ If the template displays external data (RSS, calendar, events):
1. Add `{"input": "feed", "name": "feed", ...}` to `adminForm`. The Admin will show a feed-picker; the result lands at `slide.feed` and `slide.feedData`.
2. In the renderer, consume `slide.feedData` — its shape is the **feed output model** the chosen `FeedSource` produces (see `src/Feed/OutputModel/`).
3. The Client refreshes `slide.feedData` according to `CLIENT_PULL_STRATEGY_INTERVAL` (default 10 min) — the renderer doesn't need to fetch.
4. Feed configuration values like `entryDuration` arrive in **seconds** — multiply by 1000 before handing them to a hook.

Templates are decoupled from feed implementations via the output-model contract. A new feed source that produces the same output model can power any existing template — no template changes required. See README "Feeds" for the architecture.

Expand Down Expand Up @@ -147,15 +178,19 @@ The `slide-template-reviewer` subagent checks the contract — invoke it after c

## Common mistakes

- **Forgetting `slideDone()`** — most common bug. Slide enters playlist, never advances. `BaseSlideExecution` solves the fixed-duration case.
- **Forgetting `slideDone()`** — most common bug. Slide enters playlist, never advances. `useBaseSlideExecution` solves the fixed-duration case, `useMultipleEntrySlideExecution` the cycle-then-done case.
- **Passing seconds where a hook expects milliseconds** — `entryDuration: 10` shows each entry for 10 ms; the slide is gone almost instantly and no fallback rescues it. Convert feed-config seconds at the template boundary.
- **Anchoring timers to mount instead of `run`** — a single-slide region replays by issuing a new `run` value without remounting; timers keyed to mount never restart. Key everything to `run`.
- **Reusing a ULID** — silently overwrites the other template's registration. Always generate a fresh one.
- **adminForm `name:` doesn't match what the renderer reads** — Admin writes to `slide.content.foo`, renderer reads `slide.content.bar`. Form changes appear to do nothing.
- **adminForm `name:` doesn't match what the renderer reads** — Admin writes to `slide.content.foo`, renderer reads `slide.content.bar`. Form changes appear to do nothing. (And the `duration` input only ever writes to `slide.content.duration`.)
- **Shipping only `.jsx` or only `.json`** — half-broken template. The Stop hook `claude-hook-check-template-pairs.sh` warns; the slide-template-reviewer flags as a blocker.
- **Putting custom templates in `templates/` and committing them** — they're tenant-specific; use `custom-templates/` (gitignored) or fork.

## Related

- README "Custom Templates" — full reference for `adminForm` input types and the contribution path.
- `assets/shared/custom-templates-example/` — a working example to copy from.
- `assets/shared/slide-utils/base-slide-execution.js` — the slideDone helper.
- `assets/shared/slide-utils/useBaseSlideExecution.js` — the fixed-duration slideDone helper.
- `assets/shared/slide-utils/useMultipleEntrySlideExecution.js` — the cycle-through-entries helper.
- `assets/shared/templates/video.jsx` — the guard pattern for self-managed (event-driven) slideDone.
- Subagent `slide-template-reviewer` — contract checks.
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

- Moved the empty-feed fallback into `useMultipleEntrySlideExecution` so a template cannot lock a
playlist by omitting it, and started cycling when feed entries arrive after the slide did.
- Fixed template fade timers being derived from an unclamped duration, which left an entry faded out
for the whole slide when the configured duration was zero.
- Added `docs/client-scheduling.md` describing content selection, rotation and the `slideDone` contract.

- Fixed the calendar template Playwright tests failing on the 31st of a month, where the fixed test
clock rolled over into the following month.
- Fixed the API-spec workflow failing on pull requests from forks, where the informational PR comment
Expand Down Expand Up @@ -188,6 +194,20 @@ All notable changes to this project will be documented in this file.
- Optimized release data fetching.
- Optimized list loading.
- Removed fixture length check from test.
- Introduced two hooks (useBaseSlideExecution, useMultipleEntrySlideExecution) that are used in the templates in place
of BaseSlideExecution in fixed duration slides and manual iteration over entries in slides that iterate through
elements before calling slideDone.
- Removed the `BaseSlideExecution` class (`assets/shared/slide-utils/base-slide-execution.js`), superseded by the
hooks above. Breaking for out-of-tree custom templates — see `UPGRADE.md`.
- Fixed the first entry's timing being anchored to mount rather than to run: `useMultipleEntrySlideExecution` now
reports `entryIndex` as `null` until the slide starts, and clears its state again when it stops.
- Fixed issue with Slideshow animations that would be locked to one type.
- Fixed sorting in calendar "multiple" layout.
- Fixed video progression issues. A rejected autoplay (which is what browsers do to a sound-enabled video) no longer
drops the slide instantly; the video shows controls and the duration guard progresses the playlist. The guard now
allows 10% plus five seconds for buffering stalls, and a separate 30 second guard covers a source that never reports
a usable duration. The duration guard is installed from `durationchange` as well as `loadedmetadata`, so sources that
report an infinite duration at first (fragmented WebM, streams) are no longer cut short.
- Fixed video overflow.
- Added vitest for frontend unit tests.
- Added spinner when retrieving bind key.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1075,7 +1075,7 @@ For an example of a custom template see `assets/shared/custom-templates-example/

The slide is responsible for signaling that it is done executing.
This is done by calling the slideDone() function. If the slide should just run for X milliseconds then you can use the
BaseSlideExecution class to handle this. See the example for this approach.
`useBaseSlideExecution` hook to handle this. See the example for this approach.

##### Admin Form

Expand Down
2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ tasks:
test:frontend-local:
desc: "Runs frontend tests from the local machine."
cmds:
- BASE_URL="https://display.local.itkdev.dk" npx playwright test
- BASE_URL="https://display.local.itkdev.dk" npx playwright test {{.CLI_ARGS}}

test:frontend-local-ui:
desc: "Runs frontend tests from the local machine in UI mode."
Expand Down
50 changes: 50 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- [Developer guide](#developer-guide)
- [Repository changes](#repository-changes)
- [Convert external templates to custom templates](#convert-external-templates-to-custom-templates)
- [`BaseSlideExecution` replaced by hooks](#baseslideexecution-replaced-by-hooks)
- [Removed feed types](#removed-feed-types)

## 2.x -> 3.0
Expand Down Expand Up @@ -311,6 +312,55 @@ Checklist:
- [ ] `app:templates:list` shows the custom template as available/installed.
- [ ] Existing slides using the template render in preview and on a screen.

#### `BaseSlideExecution` replaced by hooks

`assets/shared/slide-utils/base-slide-execution.js` is removed. Templates that imported it must
switch to `useBaseSlideExecution`, which owns the timer and clears it on unmount:

```jsx
// Before
import BaseSlideExecution from "../slide-utils/base-slide-execution";

const slideExecution = new BaseSlideExecution(slide, slideDone);
useEffect(() => {
if (run) {
slideExecution.start(duration);
}

return function cleanup() {
slideExecution.stop();
};
}, [run]);

// After
import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js";

useBaseSlideExecution({ slide, run, slideDone, duration });
```

Note that the "before" shape constructs the instance **during render**, so every render produced a
fresh object whose `slideTimeout` was already `null` — the cleanup then called `stop()` on that new
instance rather than the one holding the live timer, and the timer was never actually cleared. The
hook keeps its timer in a ref, so cleanup cancels the right one. Templates that copied this pattern
get the fix for free.

`duration` is in milliseconds. The hook falls back to 15000 when it is missing or non-positive; the
class had no fallback and passed the value straight to `setTimeout`, so a missing duration became
`setTimeout(fn, undefined)` and advanced the slide immediately. The fallback is new behaviour, not
parity — a template that relied on a missing `duration` skipping the slide will now hold for 15
seconds.

Templates that stepped through a list of entries themselves before calling `slideDone()` can hand
that to `useMultipleEntrySlideExecution` (`{ entries, run, slide, slideDone, entryDuration }`), which
returns `{ currentEntry, entryIndex }`. Both are `null` until the slide starts running, so guard on
that rather than assuming index `0`.

Checklist:

- [ ] No custom template imports `base-slide-execution.js`.
- [ ] Slides using converted templates advance on a screen (they lock the playlist if `slideDone()`
is never reached).

#### Removed feed types

`SparkleIOFeedType`, `EventDatabaseApiFeedType` and `KobaFeedType` are removed in 3.0 (deprecated
Expand Down
15 changes: 2 additions & 13 deletions assets/shared/custom-templates-example/custom-template-example.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useEffect } from "react";
import templateConfig from "./custom-template-example.json";
import BaseSlideExecution from "../slide-utils/base-slide-execution.js";
import useBaseSlideExecution from "../slide-utils/useBaseSlideExecution.js";
import { ThemeStyles } from "../slide-utils/slide-util.jsx";

/**
Expand Down Expand Up @@ -57,17 +56,7 @@ function CustomTemplateExample({
const { duration = 15000 } = content;
const { title = "Default title" } = content;

const slideExecution = new BaseSlideExecution(slide, slideDone);

useEffect(() => {
if (run) {
slideExecution.start(duration);
}

return function cleanup() {
slideExecution.stop();
};
}, [run]);
useBaseSlideExecution({ slide, run, slideDone, duration });

return (
<>
Expand Down
Loading