diff --git a/frontend/js/components/getting-started.js b/frontend/js/components/getting-started.js
new file mode 100644
index 0000000..bce140e
--- /dev/null
+++ b/frontend/js/components/getting-started.js
@@ -0,0 +1,142 @@
+// "What do I do next?" — answered from what the account ACTUALLY contains.
+//
+// There was already an onboarding wizard, but it is a one-time modal gated on a localStorage
+// flag: skip it once and it never returns, and it never knew whether you succeeded at anything.
+// Someone who closed it and then wondered how to get content on a screen had nothing left to go
+// on, which is exactly the confusion that got reported.
+//
+// A second tour would repeat that mistake. Tours are dismissed and forgotten, and they describe
+// the product rather than the account. This reads real state instead, so it cannot claim you have
+// done something you have not, it is still there tomorrow, and it disappears by itself once the
+// first screen is actually live — no nagging anyone who already knows the product.
+//
+// The steps are the shortest true path to a screen showing something: get a screen connected,
+// get media in, arrange it, put it on the screen.
+
+import { t } from '../i18n.js';
+
+// Pure: given what the account holds, which steps are done and which is next. Separated from the
+// DOM so the logic that decides "you are finished" is testable — a checklist that congratulates
+// you too early is worse than none.
+export function computeSteps({ devices = [], content = [], playlists = [] } = {}) {
+ const hasDevice = devices.length > 0;
+ const hasContent = content.length > 0;
+ const hasPlaylist = playlists.length > 0;
+ // "On screen" is the only step that cannot be faked by creating an object and walking away:
+ // some screen has to actually be pointed at something.
+ const isAssigned = devices.some((d) => d.playlist_id || d.default_content_id || d.layout_id);
+
+ const steps = [
+ {
+ key: 'device',
+ done: hasDevice,
+ title: t('gs.device.title'),
+ desc: t('gs.device.desc'),
+ cta: t('gs.device.cta'),
+ href: '#/',
+ action: 'add-device',
+ },
+ {
+ key: 'content',
+ done: hasContent,
+ title: t('gs.content.title'),
+ desc: t('gs.content.desc'),
+ cta: t('gs.content.cta'),
+ href: '#/content',
+ },
+ {
+ key: 'playlist',
+ done: hasPlaylist,
+ title: t('gs.playlist.title'),
+ desc: t('gs.playlist.desc'),
+ cta: t('gs.playlist.cta'),
+ href: '#/playlists',
+ },
+ {
+ key: 'assign',
+ done: isAssigned,
+ title: t('gs.assign.title'),
+ desc: t('gs.assign.desc'),
+ cta: t('gs.assign.cta'),
+ href: '#/',
+ },
+ ];
+
+ // The NEXT step is the first unfinished one — in order, because each genuinely depends on the
+ // one before it. Highlighting anything else would send someone to a screen they cannot use yet.
+ const nextIndex = steps.findIndex((s) => !s.done);
+ return {
+ steps,
+ nextIndex,
+ complete: nextIndex === -1,
+ doneCount: steps.filter((s) => s.done).length,
+ };
+}
+
+const DISMISS_KEY = 'rd_gs_dismissed';
+export const isDismissed = () => localStorage.getItem(DISMISS_KEY) === '1';
+export const dismiss = () => localStorage.setItem(DISMISS_KEY, '1');
+export const undismiss = () => localStorage.removeItem(DISMISS_KEY);
+
+// Show it while there is still something to do and the user has not put it away. Deliberately
+// NOT gated on "is this a new account" — someone who has had the product a month and still has no
+// content is exactly who needs it.
+export function shouldShow(state) {
+ return !state.complete && !isDismissed();
+}
+
+export function render(host, state, { onAction } = {}) {
+ if (!host) return;
+ if (!shouldShow(state)) { host.innerHTML = ''; host.style.display = 'none'; return; }
+ host.style.display = '';
+
+ const { steps, nextIndex, doneCount } = state;
+ host.innerHTML = `
+
`;
+
+ host.querySelector('#gsDismiss')?.addEventListener('click', () => {
+ dismiss();
+ host.innerHTML = '';
+ host.style.display = 'none';
+ });
+ host.querySelectorAll('[data-gs-step]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const step = steps.find((s) => s.key === btn.dataset.gsStep);
+ if (!step) return;
+ // An in-page action (open the pairing dialog) beats navigating somewhere and leaving the
+ // user to find the button again.
+ if (step.action && onAction && onAction(step.action)) return;
+ window.location.hash = step.href;
+ });
+ });
+}
diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js
index 0067e81..45b48a3 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -1,6 +1,25 @@
// English translations. This file is the source of truth for keys —
// every other locale should mirror its keys (or fall back to en).
export default {
+ // Getting-started checklist (components/getting-started.js). Driven by real account state,
+ // not a one-time flag, so it can tell someone what is actually left to do.
+ 'gs.title': 'Get your first screen live',
+ 'gs.progress': '{done} of {total} done',
+ 'gs.dismiss': 'Hide',
+ 'gs.device.title': 'Connect a screen',
+ 'gs.device.desc': 'Open the player on your display, then enter the code it shows.',
+ 'gs.device.cta': 'Add screen',
+ 'gs.content.title': 'Add some content',
+ 'gs.content.desc': 'Upload images or video, or add a web page or widget.',
+ 'gs.content.cta': 'Add content',
+ 'gs.playlist.title': 'Put content in a playlist',
+ 'gs.playlist.desc': 'A playlist is the running order your screen loops through.',
+ 'gs.playlist.cta': 'New playlist',
+ 'gs.assign.title': 'Send it to the screen',
+ 'gs.assign.desc': 'Open the screen and assign the playlist — it starts playing straight away.',
+ 'gs.assign.cta': 'Assign',
+ 'gs.reopen': 'Show getting-started checklist',
+
// #zone-orphan dashboard warnings
'device.pl_item.orphan_zone': 'Zone from a different layout — reassign',
'device.pl_item.orphan_zone_tip': "This item's zone isn't part of the device's current layout. It still plays (recovered into the largest zone), but reassign it to a zone in this layout.",
diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js
index 2eb42e1..1fc8601 100644
--- a/frontend/js/views/dashboard.js
+++ b/frontend/js/views/dashboard.js
@@ -3,6 +3,7 @@ import { on, off, requestScreenshot } from '../socket.js';
import { showToast } from '../components/toast.js';
import { esc, livenessBadge } from '../utils.js';
import { t, tn } from '../i18n.js';
+import * as gettingStarted from '../components/getting-started.js';
import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js';
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
@@ -273,7 +274,8 @@ export function render(container) {
-
+
+