Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
/** Split a comma-separated selector list and try each in listed order. */
export function querySelectorPrefer(selector: string | undefined | null): HTMLElement | null {
if (!selector || typeof document === "undefined") return null;
const parts = selector
.split(",")
.map((p) => p.trim())
.filter(Boolean);
for (const part of parts) {
try {
const el = document.querySelector(part);
if (el instanceof HTMLElement) return el;
} catch {
/* invalid selector fragment — skip */
}
}
return null;
}
/** True when `target` is inside an element matching any fragment of `selector`. */
export function matchesSelectorPrefer(
target: Element,
selector: string | undefined | null
): boolean {
if (!selector) return false;
const parts = selector
.split(",")
.map((p) => p.trim())
.filter(Boolean);
for (const part of parts) {
try {
if (target.closest(part)) return true;
} catch {
/* invalid selector fragment — skip */
}
}
return false;
}
+20
View File
@@ -0,0 +1,20 @@
export {
TUTORIAL_STEPS,
TUTORIAL_SECTIONS,
stepIndexById,
stepsForSection,
sectionIndexForStep,
firstStepIndexForSection
} from "./steps";
export { tutorial } from "./state.svelte";
export { readTutorialProgress, writeTutorialProgress, TUTORIAL_STORAGE_KEY } from "./storage";
export { querySelectorPrefer, matchesSelectorPrefer } from "./dom";
export type {
TutorialStep,
TutorialProgress,
TutorialStatus,
TutorialPlacement,
TutorialAdvance,
TutorialSection,
TutorialSectionId
} from "./types";
+326
View File
@@ -0,0 +1,326 @@
import { goto } from "$app/navigation";
import { trackEvent } from "$lib/analytics";
import { querySelectorPrefer } from "./dom";
import {
TUTORIAL_SECTIONS,
TUTORIAL_STEPS,
firstStepIndexForSection,
stepIndexById
} from "./steps";
import { readTutorialProgress, writeTutorialProgress } from "./storage";
import type { TutorialProgress, TutorialSectionId, TutorialStep } from "./types";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function createTutorialController() {
let active = $state(false);
let stepIndex = $state(0);
let progress = $state<TutorialProgress>(readTutorialProgress());
let targetRect = $state<DOMRect | null>(null);
let missingTarget = $state(false);
let skipEpoch = 0;
/** When true, overlay shows section picker before / instead of a step. */
let sectionPickerOpen = $state(false);
const step = $derived(TUTORIAL_STEPS[stepIndex] ?? TUTORIAL_STEPS[0]);
const total = TUTORIAL_STEPS.length;
const sections = TUTORIAL_SECTIONS;
const canResume = $derived(
progress.status === "in_progress" && typeof progress.stepId === "string"
);
const isDone = $derived(progress.status === "completed");
const wasSkipped = $derived(progress.status === "skipped");
const canRestart = $derived(isDone || wasSkipped);
function persist(status: TutorialProgress["status"], stepId: string | null) {
progress = writeTutorialProgress(status, stepId);
}
function currentStep(): TutorialStep {
return TUTORIAL_STEPS[stepIndex] ?? TUTORIAL_STEPS[0];
}
async function ensureRoute(s: TutorialStep) {
if (!s.route || s.softRoute || typeof window === "undefined") return;
const path = window.location.pathname;
const matches = s.routeExact
? path === s.route
: path === s.route || path.startsWith(s.route);
if (!matches) {
await goto(s.route);
}
}
function measureTarget() {
const s = currentStep();
if (!s.selector || typeof document === "undefined") {
targetRect = null;
missingTarget = false;
return;
}
const el = querySelectorPrefer(s.selector);
if (!el) {
targetRect = null;
missingTarget = true;
return;
}
missingTarget = false;
el.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
targetRect = el.getBoundingClientRect();
}
async function waitForTarget(maxMs: number): Promise<boolean> {
const s = currentStep();
if (!s.selector) {
missingTarget = false;
targetRect = null;
return true;
}
const deadline = Date.now() + maxMs;
while (Date.now() < deadline) {
measureTarget();
if (!missingTarget) return true;
await sleep(50);
}
measureTarget();
return !missingTarget;
}
async function showStep(index: number) {
const epoch = ++skipEpoch;
sectionPickerOpen = false;
const clamped = Math.max(0, Math.min(index, TUTORIAL_STEPS.length - 1));
stepIndex = clamped;
const s = currentStep();
active = true;
persist("in_progress", s.id);
trackEvent("tutorial_step_view", {
step_id: s.id,
section_id: s.sectionId,
step_index: clamped
});
await ensureRoute(s);
if (epoch !== skipEpoch) return;
// Soft-route steps may need longer for client navigation + data load.
const waitMs = s.softRoute ? 1200 : 700;
await waitForTarget(waitMs);
if (epoch !== skipEpoch) return;
// Missing target is OK in demo mode — Continue still works.
requestAnimationFrame(() => {
if (epoch !== skipEpoch) return;
measureTarget();
setTimeout(() => {
if (epoch === skipEpoch) measureTarget();
}, 160);
});
}
async function start(fromBeginning = true) {
sectionPickerOpen = false;
const startIdx = fromBeginning ? 0 : stepIndexById(progress.stepId);
if (fromBeginning) {
trackEvent("tutorial_start", { entry: "beginning" });
}
await showStep(startIdx);
}
async function resume() {
if (!canResume) {
await start(true);
return;
}
await showStep(stepIndexById(progress.stepId));
}
/** Restart from step 0 (clears completed / skipped). */
async function restart() {
persist("in_progress", TUTORIAL_STEPS[0]?.id ?? null);
trackEvent("tutorial_start", { entry: "beginning" });
await showStep(0);
}
async function goToStep(stepId: string) {
await showStep(stepIndexById(stepId));
}
async function startSection(sectionId: TutorialSectionId) {
trackEvent("tutorial_start", { entry: "section", section_id: sectionId });
await showStep(firstStepIndexForSection(sectionId));
}
/** Open tour with section jump list (header “Browse sections”). */
async function openSectionPicker() {
skipEpoch += 1;
sectionPickerOpen = true;
active = true;
targetRect = null;
missingTarget = false;
if (progress.status === "idle" || progress.status === "completed" || progress.status === "skipped") {
persist("in_progress", progress.stepId ?? TUTORIAL_STEPS[0]?.id ?? null);
}
}
function closeSectionPicker() {
sectionPickerOpen = false;
}
async function next() {
const s = currentStep();
if (s.id === "tour-done" || stepIndex >= TUTORIAL_STEPS.length - 1) {
complete();
return;
}
await showStep(stepIndex + 1);
}
async function back() {
if (sectionPickerOpen) {
sectionPickerOpen = false;
if (progress.stepId) {
await showStep(stepIndexById(progress.stepId));
}
return;
}
if (stepIndex <= 0) return;
await showStep(stepIndex - 1);
}
function skip() {
const s = currentStep();
skipEpoch += 1;
active = false;
sectionPickerOpen = false;
targetRect = null;
persist("skipped", s.id);
trackEvent("tutorial_dismiss", { step_id: s.id, section_id: s.sectionId });
}
function complete() {
skipEpoch += 1;
active = false;
sectionPickerOpen = false;
targetRect = null;
persist("completed", "tour-done");
trackEvent("tutorial_complete", { steps_completed: TUTORIAL_STEPS.length });
}
function dismiss() {
const s = currentStep();
skipEpoch += 1;
active = false;
sectionPickerOpen = false;
targetRect = null;
if (progress.status === "in_progress") {
persist("in_progress", s.id);
}
trackEvent("tutorial_dismiss", { step_id: s.id, section_id: s.sectionId });
}
function advanceAfterAction() {
const s = currentStep();
if (s.id === "tour-done" || stepIndex >= TUTORIAL_STEPS.length - 1) {
complete();
return;
}
setTimeout(() => {
void showStep(stepIndex + 1);
}, 220);
}
/** Call when a highlighted control is clicked during a click-advance step. */
function notifyTargetClicked() {
const s = currentStep();
if (!active || sectionPickerOpen || s.advanceOn !== "click") return;
advanceAfterAction();
}
/**
* Optional: pages may still report successful actions.
* Demo tour advances via Continue; this is a no-op unless advanceOn is action
* and the user is on that step (optional shortcut).
*/
function reportAction(stepId: string) {
const idx = TUTORIAL_STEPS.findIndex((s) => s.id === stepId);
const target = idx >= 0 ? TUTORIAL_STEPS[idx] : null;
if (!target || target.advanceOn !== "action") return;
if (active) {
if (sectionPickerOpen || currentStep().id !== stepId) return;
} else if (progress.status !== "in_progress" || progress.stepId !== stepId) {
return;
} else {
stepIndex = idx;
}
advanceAfterAction();
}
function hydrateFromStorage() {
progress = readTutorialProgress();
}
return {
get active() {
return active;
},
get stepIndex() {
return stepIndex;
},
get step() {
return step;
},
get total() {
return total;
},
get sections() {
return sections;
},
get progress() {
return progress;
},
get targetRect() {
return targetRect;
},
get missingTarget() {
return missingTarget;
},
get sectionPickerOpen() {
return sectionPickerOpen;
},
get canResume() {
return canResume;
},
get isDone() {
return isDone;
},
get wasSkipped() {
return wasSkipped;
},
get canRestart() {
return canRestart;
},
start,
resume,
restart,
goToStep,
startSection,
openSectionPicker,
closeSectionPicker,
next,
back,
skip,
complete,
dismiss,
measureTarget,
notifyTargetClicked,
reportAction,
hydrateFromStorage,
ensureRoute
};
}
export const tutorial = createTutorialController();
+184
View File
@@ -0,0 +1,184 @@
import { i18n } from "$lib/i18n";
import type { TutorialSection, TutorialSectionId, TutorialStep } from "./types";
/**
* Safe guided demo tour — overlay on the live dashboard.
* Continue / Back always work; never require Enable / Save / Sync / Create.
* Core path (aligned with activation + dashboard workflow):
* Feeds → Map → Sync → Process → Export / Stores.
* Title/body/tip/warning/missingHint/nextLabel live in `$lib/i18n`
* as `tutorial.step.<id>.*` and section labels as `tutorial.section.<id>`.
*/
export const TUTORIAL_STEPS: TutorialStep[] = [
{
id: "welcome",
sectionId: "welcome",
route: "/dashboard",
routeExact: true,
selector: '[data-tour="dashboard-welcome"]',
placement: "bottom",
advanceOn: "next"
},
{
id: "dashboard-overview",
sectionId: "welcome",
route: "/dashboard",
routeExact: true,
// Prefer the catalog workflow strip (feeds → map → sync → process).
selector:
'[data-tour="dashboard-workflow"],[data-tour="activation-core-path"],[data-tour="activation-checklist"],[data-tour="stats-products"]',
placement: "bottom",
advanceOn: "next"
},
{
id: "enable-fields",
sectionId: "catalog",
route: "/standard-fields",
routeExact: true,
selector: '[data-tour="enable-recommended"]',
placement: "bottom",
advanceOn: "next"
},
{
id: "catalog-depth",
sectionId: "catalog",
route: "/standard-fields",
routeExact: true,
selector:
'[data-tour="nav-categories"],[data-tour="nav-attributes"],[data-tour="nav-standard-fields"]',
placement: "right",
advanceOn: "next"
},
{
id: "connect-source",
sectionId: "sources",
route: "/feeds",
routeExact: true,
selector: '[data-tour="feeds-add"],[data-tour="feeds-empty-add"],[data-tour="nav-feeds"]',
placement: "bottom",
advanceOn: "next"
},
{
id: "map",
sectionId: "mapping",
route: "/feeds",
routeExact: true,
selector:
'[data-tour="feed-open-mapping"],[data-tour="feeds-activation-focus"],[data-tour="feeds-add"],[data-tour="feeds-empty-add"]',
placement: "left",
advanceOn: "next"
},
{
id: "sync-sample",
sectionId: "mapping",
route: "/feeds",
routeExact: true,
selector:
'[data-tour="feed-sync-now"],[data-tour="feed-sync-process-sample"],[data-tour="feed-open-mapping"],[data-tour="feeds-activation-focus"]',
placement: "top",
advanceOn: "next"
},
{
id: "process",
sectionId: "products",
route: "/products",
selector: '[data-tour="products-table"],[data-tour="nav-products"],[data-tour="start-processing"]',
placement: "top",
advanceOn: "next"
},
{
id: "processing-hub",
sectionId: "products",
route: "/processing",
routeExact: true,
selector:
'[data-tour="processing-page"],[data-tour="nav-processing"],[data-tour="start-processing"]',
placement: "bottom",
advanceOn: "next"
},
{
id: "stores-hub",
sectionId: "export",
route: "/stores",
routeExact: true,
selector: '[data-tour="store-hub"],[data-tour="nav-stores"]',
placement: "bottom",
advanceOn: "next"
},
{
id: "export",
sectionId: "export",
route: "/export-feeds",
routeExact: true,
selector: '[data-tour="export-create"],[data-tour="nav-export-feeds"]',
placement: "bottom",
advanceOn: "next"
},
{
id: "billing-hub",
sectionId: "export",
route: "/billing",
routeExact: true,
selector: '[data-tour="billing-page"],[data-tour="nav-billing"],[data-tour="stats-credits"]',
placement: "bottom",
advanceOn: "next"
},
{
id: "grow-hub",
sectionId: "grow",
route: "/campaigns",
routeExact: true,
selector: '[data-tour="campaigns-page"],[data-tour="nav-campaigns"]',
placement: "bottom",
advanceOn: "next"
},
{
id: "tour-done",
sectionId: "done",
route: "/dashboard",
routeExact: true,
selector: '[data-tour="start-tutorial"],[data-tour="dashboard-welcome"],[data-tour="dashboard-workflow"]',
placement: "bottom",
advanceOn: "next"
}
];
export const TUTORIAL_SECTIONS: TutorialSection[] = (() => {
const seen = new Set<TutorialSectionId>();
const out: TutorialSection[] = [];
for (const step of TUTORIAL_STEPS) {
if (seen.has(step.sectionId)) continue;
seen.add(step.sectionId);
const sectionId = step.sectionId;
out.push({
id: sectionId,
get label() {
return i18n.t(`tutorial.section.${sectionId}`);
},
startStepId: step.id
});
}
return out;
})();
export function stepIndexById(id: string | null | undefined): number {
if (!id) return 0;
const idx = TUTORIAL_STEPS.findIndex((s) => s.id === id);
return idx >= 0 ? idx : 0;
}
export function stepsForSection(sectionId: TutorialSectionId): TutorialStep[] {
return TUTORIAL_STEPS.filter((s) => s.sectionId === sectionId);
}
export function sectionIndexForStep(stepId: string | null | undefined): number {
const step = TUTORIAL_STEPS[stepIndexById(stepId)];
if (!step) return 0;
const idx = TUTORIAL_SECTIONS.findIndex((s) => s.id === step.sectionId);
return idx >= 0 ? idx : 0;
}
export function firstStepIndexForSection(sectionId: TutorialSectionId): number {
const idx = TUTORIAL_STEPS.findIndex((s) => s.sectionId === sectionId);
return idx >= 0 ? idx : 0;
}
+59
View File
@@ -0,0 +1,59 @@
import type { TutorialProgress, TutorialStatus } from "./types";
export const TUTORIAL_STORAGE_KEY = "descrybe.tutorial.v2";
/** Bumped when tour step ids change so stale in-progress cursors reset. */
export const TUTORIAL_PROGRESS_VERSION = 4;
const idleProgress = (): TutorialProgress => ({
version: TUTORIAL_PROGRESS_VERSION,
status: "idle",
stepId: null,
updatedAt: new Date().toISOString()
});
export function readTutorialProgress(): TutorialProgress {
if (typeof localStorage === "undefined") return idleProgress();
try {
const raw = localStorage.getItem(TUTORIAL_STORAGE_KEY);
if (!raw) return idleProgress();
const parsed = JSON.parse(raw) as Partial<TutorialProgress>;
if (parsed.version !== TUTORIAL_PROGRESS_VERSION) return idleProgress();
const status = parsed.status;
if (
status !== "idle" &&
status !== "in_progress" &&
status !== "completed" &&
status !== "skipped"
) {
return idleProgress();
}
return {
version: TUTORIAL_PROGRESS_VERSION,
status,
stepId: typeof parsed.stepId === "string" ? parsed.stepId : null,
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString()
};
} catch {
return idleProgress();
}
}
export function writeTutorialProgress(
status: TutorialStatus,
stepId: string | null
): TutorialProgress {
const next: TutorialProgress = {
version: TUTORIAL_PROGRESS_VERSION,
status,
stepId,
updatedAt: new Date().toISOString()
};
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem(TUTORIAL_STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota / private mode */
}
}
return next;
}
+58
View File
@@ -0,0 +1,58 @@
export type TutorialPlacement = "top" | "bottom" | "left" | "right" | "center";
/**
* next — informational; Continue / Done always available (demo default).
* click — optional: clicking the highlight advances, but Continue still works.
* action — legacy: page may call reportAction(stepId); Continue still works in demo mode.
*/
export type TutorialAdvance = "next" | "click" | "action";
export type TutorialStatus = "idle" | "in_progress" | "completed" | "skipped";
export type TutorialSectionId =
| "welcome"
| "catalog"
| "sources"
| "mapping"
| "products"
| "export"
| "grow"
| "done";
export type TutorialSection = {
id: TutorialSectionId;
label: string;
/** First step id in this section. */
startStepId: string;
};
export type TutorialStep = {
id: string;
/** Title/body/tip/warning/missingHint/nextLabel live in i18n as `tutorial.step.<id>.*`. */
sectionId: TutorialSectionId;
/** Route to ensure before highlighting (pathname prefix match unless exact). */
route?: string;
/** Exact pathname match when true. */
routeExact?: boolean;
/**
* When true, do not navigate — wait until the user reaches a matching screen
* (e.g. feed mapping URLs with unknown ids).
*/
softRoute?: boolean;
/**
* CSS selector(s). Prefer `[data-tour="…"]`.
* Comma-separated lists are tried in listed order (first match wins),
* not document order.
*/
selector?: string;
placement?: TutorialPlacement;
/** How the step advances. Default next. */
advanceOn?: TutorialAdvance;
};
export type TutorialProgress = {
version: number;
status: TutorialStatus;
stepId: string | null;
updatedAt: string;
};