PageSourceSearch

https://railsblocks.com/assets/controllers/onboarding_checklist_co…555e32e8f86b2cce939d7d3674d568bbe3db0aaf.js

js railsblocks.com collected 2026-09-25 20:23:46 UTC 54,085 bytes, 1,495 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2import { autoUpdate, computePosition, offset, shift } from "@floating-ui/dom";
3import "number-flow";
4import { continuous } from "number-flow";
5
6export default class extends Controller {
7  static targets = [
8    "panel",
9    "trigger",
10    "chevron",
11    "ring",
12    "progress",
13    "summary",
14    "taskList",
15    "liveRegion",
16    "modeButton",
17    "surface",
18    "demoButton",
19    "ready",
20    "progressVisual",
21    "completionHalo",
22    "remaining",
23    "title",
24    "panelTitle",
25    "panelDescription",
26  ];
27
28  static values = {
29    data: String,
30    open: { type: Boolean, default: false },
31    mode: { type: String, default: "overlay-below" },
32    compact: { type: Boolean, default: false },
33  };
34
35  connect() {
36    // Cache panel-owned targets before overlay modes portal the panel outside
37    // this Stimulus controller's DOM scope.
38    this.panelElement = this.panelTarget;
39    this.taskListElement = this.taskListTarget;
40    this.remainingElement = this.hasRemainingTarget ? this.remainingTarget : null;
41    this.panelTitleElement = this.hasPanelTitleTarget ? this.panelTitleTarget : null;
42    this.panelDescriptionElement = this.hasPanelDescriptionTarget ? this.panelDescriptionTarget : null;
43    this.demoButtonElement = this.hasDemoButtonTarget ? this.demoButtonTarget : null;
44    this.surfaceElement = this.hasSurfaceTarget ? this.surfaceTarget : this.element;
45    this.triggerElement = this.hasTriggerTarget ? this.triggerTarget : null;
46    this.chevronElement = this.hasChevronTarget ? this.chevronTarget : null;
47    this.ringElement = this.hasRingTarget ? this.ringTarget : null;
48    this.progressElement = this.hasProgressTarget ? this.progressTarget : null;
49    this.summaryElement = this.hasSummaryTarget ? this.summaryTarget : null;
50    this.liveRegionElement = this.hasLiveRegionTarget ? this.liveRegionTarget : null;
51    this.completionHaloElement = this.hasCompletionHaloTarget ? this.completionHaloTarget : null;
52    this.progressVisualElement = this.hasProgressVisualTarget ? this.progressVisualTarget : null;
53    this.originalPanelParent = this.panelElement.parentNode;
54    this.originalPanelNextSibling = this.panelElement.nextSibling;
55    this.isPanelPortaled = false;
56    this.overlayPositionCleanup = null;
57    this.panelOpenSequence = 0;
58    this.panelClickHandler = this.handlePortaledPanelClick.bind(this);
59    this.panelElement.addEventListener("click", this.panelClickHandler);
60
61    this.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
62    this.config = this.parseData();
63    this.tasks = this.cloneTasks(this.config.tasks || []);
64    this.bindCompletionEvents();
65    this.bindUpdateEvent();
66    this.initialTasks = this.cloneTasks(this.tasks);
67    this.timeoutIds = [];
68    this.batchRunning = false;
69    this.completionAnnounced = false;
70    this.dismissing = false;
71    this.progressInitialized = false;
72    this.pendingInitialPercentage = 0;
73    this.ringAnimationFrame = null;
74    this.panelAnimation = null;
75    this.completionAnimations = [];
76    this.successAnimations = [];
77    this.successParticles = [];
78    this.configureProgressFlow();
79    this.currentPercentage = 0;
80    this.progressElement?.update(0);
81    this.connected = true;
82    this.render();
83    this.applyMode();
84    this.paintOpenState({ animate: false });
85  }
86
87  disconnect() {
88    this.connected = false;
89    this.clearPendingTimeouts();
90    window.cancelAnimationFrame(this.ringAnimationFrame);
91    this.stopAnimations();
92    this.unbindCompletionEvents();
93    this.unbindUpdateEvent();
94    this.stopOverlayPositioning();
95    this.panelElement?.removeEventListener("click", this.panelClickHandler);
96    this.restorePanelMount();
97  }
98
99  dataValueChanged() {
100    if (!this.connected) return;
101
102    this.config = this.parseData();
103    this.tasks = this.cloneTasks(this.config.tasks || []);
104    this.bindCompletionEvents();
105    this.bindUpdateEvent();
106    this.initialTasks = this.cloneTasks(this.tasks);
107    this.batchRunning = false;
108    this.completionAnnounced = false;
109    this.dismissing = false;
110    this.render();
111  }
112
113  toggle() {
114    this.openValue = !this.openValue;
115    this.paintOpenState();
116  }
117
118  close(event) {
119    if (!this.openValue || !this.isOverlayMode()) return;
120    if (this.surfaceElement.contains(event.target) || this.panelElement.contains(event.target)) return;
121
122    this.openValue = false;
123    this.paintOpenState();
124  }
125
126  escape() {
127    if (!this.openValue) return;
128
129    this.openValue = false;
130    this.paintOpenState();
131  }
132
133  changeMode(event) {
134    const nextMode = event.currentTarget.dataset.mode;
135    if (!nextMode || nextMode === this.modeValue) return;
136
137    this.modeValue = nextMode;
138    this.applyMode();
139    this.paintOpenState({ animate: false });
140  }
141
142  handlePortaledPanelClick(event) {
143    if (!this.isPanelPortaled || !(event.target instanceof Element)) return;
144
145    const actionElement = event.target.closest("[data-action]");
146    if (!actionElement || !this.panelElement.contains(actionElement)) return;
147
148    const actions = (actionElement.dataset.action || "").split(/\s+/);
149    const delegatedActions = {
150      completeAll: () => this.completeAll(),
151      complete: () => this.complete({ currentTarget: actionElement }),
152      focusTask: () => this.focusTask({ currentTarget: actionElement }),
153      dispatchTaskAction: () => this.dispatchTaskAction({ currentTarget: actionElement }),
154      toggleSkip: () => this.toggleSkip({ currentTarget: actionElement }),
155    };
156
157    for (const [method, callback] of Object.entries(delegatedActions)) {
158      if (!actions.some((action) => action.endsWith(`->onboarding-checklist#${method}`))) continue;
159
160      event.preventDefault();
161      callback();
162      break;
163    }
164  }
165
166  dispatchTaskAction(event) {
167    const task = this.taskFor(event.currentTarget.dataset.taskId);
168    const eventName = task?.actionEvent || task?.action_event;
169    if (typeof eventName !== "string" || eventName.trim() === "") return;
170
171    this.element.dispatchEvent(
172      new CustomEvent(eventName, {
173        bubbles: true,
174        detail: {
175          checklistId: this.checklistId(),
176          checklist_id: this.checklistId(),
177          task: { ...task },
178          taskId: task.id,
179          task_id: task.id,
180        },
181      }),
182    );
183
184    if (task.closeOnAction || task.close_on_action) {
185      this.openValue = false;
186      this.paintOpenState();
187    }
188  }
189
190  complete(event) {
191    const task = this.taskFor(event.currentTarget.dataset.taskId);
192    if (this.batchRunning || !task || ["completed", "loading", "skipped"].includes(task.status)) return;
193
194    task.status = "loading";
195    this.render();
196    this.announce(`${task.title} is in progress`);
197
198    this.schedule(() => {
199      task.status = "completed";
200      this.render();
201      this.announce(`${task.title} completed`);
202      this.celebrate(task.id);
203    }, 1100);
204  }
205
206  completeAll() {
207    const incompleteTasks = this.tasks.filter(({ status }) => !["completed", "skipped"].includes(status));
208
209    if (incompleteTasks.length === 0) {
210      this.clearPendingTimeouts();
211      this.stopSuccessAnimations();
212      this.batchRunning = false;
213      this.tasks = this.cloneTasks(this.initialTasks);
214      this.render();
215      this.announce("Checklist progress reset");
216      return;
217    }
218
219    if (this.batchRunning) return;
220
221    this.batchRunning = true;
222    this.render();
223
224    incompleteTasks.forEach((task, index) => {
225      const startDelay = index * 620;
226
227      this.schedule(() => {
228        task.status = "loading";
229        this.render();
230        this.announce(`${task.title} is in progress`);
231      }, startDelay);
232
233      this.schedule(() => {
234        task.status = "completed";
235        this.render();
236        this.celebrate(task.id);
237
238        if (index === incompleteTasks.length - 1) {
239          this.batchRunning = false;
240          this.render();
241          this.announce("Onboarding complete — your workspace is ready");
242        }
243      }, startDelay + 520);
244    });
245  }
246
247  schedule(callback, delay) {
248    const timeoutId = window.setTimeout(() => {
249      this.timeoutIds = this.timeoutIds.filter((id) => id !== timeoutId);
250      callback();
251    }, delay);
252
253    this.timeoutIds.push(timeoutId);
254  }
255
256  animateElement(element, keyframes, options = {}) {
257    const animation = element.animate(keyframes, {
258      fill: "forwards",
259      ...options,
260    });
261
262    return {
263      finished: animation.finished.catch(() => {}),
264      stop: () => animation.cancel(),
265    };
266  }
267
268  setSurfaceTransitionDuration(element, { opening, overlay = this.isOverlayMode() }) {
269    const durationClass = overlay
270      ? opening
271        ? "duration-300"
272        : "duration-200"
273      : opening
274        ? "duration-[450ms]"
275        : "duration-300";
276
277    element.classList.remove("duration-200", "duration-300", "duration-[450ms]");
278    element.classList.add(durationClass);
279  }
280
281  transitionInlinePanel({ opening, wasHidden = false }) {
282    const panel = this.panelElement;
283    const duration = opening ? 450 : 300;
284    const startHeight = opening
285      ? wasHidden
286        ? 0
287        : panel.getBoundingClientRect().height
288      : panel.getBoundingClientRect().height;
289    const endHeight = opening ? panel.scrollHeight : 0;
290    let animationFrame = null;
291    let timeoutId = null;
292    let resolveFinished;
293
294    const finished = new Promise((resolve) => {
295      resolveFinished = resolve;
296    });
297
298    const finish = () => {
299      panel.removeEventListener("transitionend", handleTransitionEnd);
300      window.clearTimeout(timeoutId);
301      resolveFinished();
302    };
303
304    const handleTransitionEnd = (event) => {
305      if (event.target === panel && event.propertyName === "height") finish();
306    };
307
308    this.setSurfaceTransitionDuration(panel, { opening, overlay: false });
309    panel.style.height = `${startHeight}px`;
310    panel.style.opacity = opening && wasHidden ? "0" : "1";
311    panel.style.transform = "";
312    panel.getBoundingClientRect();
313
314    panel.addEventListener("transitionend", handleTransitionEnd);
315    animationFrame = window.requestAnimationFrame(() => {
316      panel.style.height = `${endHeight}px`;
317      panel.style.opacity = opening ? "1" : "0";
318    });
319    timeoutId = window.setTimeout(finish, duration + 80);
320
321    return {
322      finished,
323      stop: () => {
324        window.cancelAnimationFrame(animationFrame);
325        const styles = window.getComputedStyle(panel);
326        panel.style.height = styles.height;
327        panel.style.opacity = styles.opacity;
328        finish();
329      },
330    };
331  }
332
333  transitionOverlayPanel({ opening, wasHidden = false }) {
334    const panel = this.panelElement;
335    const duration = opening ? 300 : 200;
336    const direction = this.isOverlayBelow() ? -1 : 1;
337    const animationFrames = [];
338    let timeoutId = null;
339    let resolveFinished;
340    let finishedTransition = false;
341
342    const finished = new Promise((resolve) => {
343      resolveFinished = resolve;
344    });
345
346    const finish = () => {
347      if (finishedTransition) return;
348      finishedTransition = true;
349      panel.removeEventListener("transitionend", handleTransitionEnd);
350      window.clearTimeout(timeoutId);
351      resolveFinished();
352    };
353
354    const handleTransitionEnd = (event) => {
355      if (event.target === panel && event.propertyName === "transform") finish();
356    };
357
358    this.setSurfaceTransitionDuration(panel, { opening, overlay: true });
359    panel.style.height = "auto";
360    panel.style.opacity = opening && wasHidden ? "0" : "1";
361    panel.style.transform = opening && wasHidden ? `translateY(${direction * 6}px)` : "translateY(0)";
362    panel.getBoundingClientRect();
363
364    panel.addEventListener("transitionend", handleTransitionEnd);
365    animationFrames.push(
366      window.requestAnimationFrame(() => {
367        panel.style.visibility = "";
368        animationFrames.push(
369          window.requestAnimationFrame(() => {
370            panel.style.opacity = opening ? "1" : "0";
371            panel.style.transform = opening ? "translateY(0)" : `translateY(${direction * 5}px)`;
372          }),
373        );
374      }),
375    );
376    timeoutId = window.setTimeout(finish, duration + 80);
377
378    return {
379      finished,
380      stop: () => {
381        animationFrames.forEach((frame) => window.cancelAnimationFrame(frame));
382        const styles = window.getComputedStyle(panel);
383        panel.style.opacity = styles.opacity;
384        panel.style.transform = styles.transform;
385        finish();
386      },
387    };
388  }
389
390  async prepareOverlayOpen({ wasHidden, animateChange, sequence }) {
391    const direction = this.isOverlayBelow() ? -1 : 1;
392
393    this.mountPanelForOverlay();
394    this.panelElement.style.visibility = "hidden";
395    this.panelElement.style.height = "auto";
396    this.panelElement.style.opacity = animateChange && wasHidden ? "0" : "1";
397    this.panelElement.style.transform = animateChange && wasHidden ? `translateY(${direction * 6}px)` : "translateY(0)";
398    this.panelElement.hidden = false;
399
400    await this.positionOverlay();
401    if (sequence !== this.panelOpenSequence || !this.openValue || !this.isOverlayMode()) return;
402
403    this.startOverlayPositioning();
404
405    if (animateChange) {
406      this.panelAnimation = this.transitionOverlayPanel({ opening: true, wasHidden });
407    } else {
408      this.panelElement.style.visibility = "";
409    }
410  }
411
412  clearPendingTimeouts() {
413    this.timeoutIds.forEach((timeoutId) => window.clearTimeout(timeoutId));
414    this.timeoutIds = [];
415  }
416
417  cloneTasks(tasks) {
418    return tasks.map((task) => ({ ...task }));
419  }
420
421  taskFor(taskId) {
422    return this.tasks.find(({ id }) => String(id) === String(taskId));
423  }
424
425  checklistId() {
426    return this.config.checklistId || this.config.checklist_id || null;
427  }
428
429  bindUpdateEvent() {
430    this.unbindUpdateEvent();
431    this.updateEventName = this.config.updateEvent || this.config.update_event || "onboarding-checklist:update";
432    this.updateEventHandler = this.updateTasks.bind(this);
433    document.addEventListener(this.updateEventName, this.updateEventHandler);
434  }
435
436  unbindUpdateEvent() {
437    if (this.updateEventName && this.updateEventHandler) {
438      document.removeEventListener(this.updateEventName, this.updateEventHandler);
439    }
440
441    this.updateEventName = null;
442    this.updateEventHandler = null;
443  }
444
445  updateTasks(event) {
446    const detail = event.detail || {};
447    const targetChecklistId = detail.checklistId || detail.checklist_id;
448    if (targetChecklistId && String(targetChecklistId) !== String(this.checklistId())) return;
449
450    const updates = Array.isArray(detail.tasks) ? detail.tasks : detail.task ? [detail.task] : [];
451    if (updates.length === 0 && !detail.replace) return;
452
453    const wasChecklistComplete = this.isChecklistComplete();
454    const previouslyComplete = new Set(
455      this.tasks.filter(({ status }) => status === "completed").map(({ id }) => String(id)),
456    );
457
458    if (detail.replace) {
459      this.tasks = this.cloneTasks(updates);
460    } else {
461      updates.forEach((update) => {
462        if (update?.id === undefined || update?.id === null) return;
463
464        const taskIndex = this.tasks.findIndex(({ id }) => String(id) === String(update.id));
465        if (update.remove) {
466          if (taskIndex >= 0) this.tasks.splice(taskIndex, 1);
467        } else if (taskIndex >= 0) {
468          this.tasks[taskIndex] = { ...this.tasks[taskIndex], ...update };
469        } else {
470          this.tasks.push({ ...update });
471        }
472      });
473    }
474
475    this.bindCompletionEvents();
476    this.render();
477
478    const newlyCompleted = this.tasks.filter(
479      (task) => task.status === "completed" && !previouslyComplete.has(String(task.id)),
480    );
481    const latestCompleted = newlyCompleted.at(-1);
482    if (latestCompleted) {
483      this.announce(`${latestCompleted.title} completed`);
484      this.celebrate(latestCompleted.id);
485    } else if (!wasChecklistComplete && this.isChecklistComplete()) {
486      this.handleChecklistComplete();
487    }
488  }
489
490  bindCompletionEvents() {
491    this.unbindCompletionEvents();
492    this.completionEventHandlers = [];
493
494    this.tasks.forEach((task) => {
495      const eventName = task.completionEvent || task.completion_event;
496      const completionSelector = task.completionSelector || task.completion_selector;
497      const hasEvent = typeof eventName === "string" && eventName.trim() !== "";
498      const hasSelector = typeof completionSelector === "string" && completionSelector.trim() !== "";
499      if (!hasEvent && !hasSelector) return;
500
501      const handler = () => {
502        if (task.status === "completed" || task.status === "skipped") return;
503
504        task.status = "completed";
505        this.render();
506        this.announce(`${task.title} completed`);
507        this.celebrate(task.id);
508        this.persistTaskCompletion(task);
509      };
510
511      if (hasEvent) {
512        document.addEventListener(eventName, handler);
513        this.completionEventHandlers.push({ eventName, handler });
514      }
515
516      if (hasSelector) {
517        try {
518          if (document.querySelector(completionSelector)) window.queueMicrotask(handler);
519        } catch (_error) {
520          // Ignore invalid app-provided selectors without breaking the checklist.
521        }
522      }
523    });
524  }
525
526  unbindCompletionEvents() {
527    this.completionEventHandlers?.forEach(({ eventName, handler }) => {
528      document.removeEventListener(eventName, handler);
529    });
530    this.completionEventHandlers = [];
531  }
532
533  async persistTaskCompletion(task) {
534    const completionUrl = task.completionUrl || task.completion_url;
535    if (!completionUrl) return;
536
537    try {
538      const token = document.querySelector('meta[name="csrf-token"]')?.content;
539      const response = await fetch(completionUrl, {
540        method: task.completionMethod || task.completion_method || "PATCH",
541        headers: {
542          "Content-Type": "application/json",
543          Accept: "application/json",
544          ...(token ? { "X-CSRF-Token": token } : {}),
545        },
546        body: JSON.stringify({
547          task_id: task.id,
548          completed: true,
549          ...(task.completionPayload || task.completion_payload || {}),
550        }),
551      });
552
553      if (!response.ok) throw new Error(`Request failed with ${response.status}`);
554    } catch (error) {
555      console.warn("Could not persist onboarding checklist completion.", error);
556    }
557  }
558
559  focusTask(event) {
560    const task = this.taskFor(event.currentTarget.dataset.taskId);
561    const selector = task?.focus || task?.focusSelector || task?.focus_selector;
562    if (!selector) return;
563
564    let target;
565    try {
566      target = document.querySelector(selector);
567    } catch (_error) {
568      return;
569    }
570    if (!target) return;
571
572    const revealTarget = () => {
573      target.scrollIntoView({
574        behavior: this.reducedMotion.matches ? "auto" : "smooth",
575        block: "center",
576      });
577
578      window.requestAnimationFrame(() => {
579        if (typeof target.focus === "function") target.focus({ preventScroll: true });
580      });
581    };
582
583    if (this.openValue) {
584      this.openValue = false;
585      this.paintOpenState();
586      this.panelAnimation?.finished.then(revealTarget);
587    } else {
588      revealTarget();
589    }
590  }
591
592  async toggleSkip(event) {
593    const task = this.taskFor(event.currentTarget.dataset.taskId);
594    if (!task || task.status === "loading" || task.status === "completed") return;
595
596    const previousStatus = task.status;
597    const skipped = previousStatus !== "skipped";
598    const skipUrl = task.skipUrl || task.skip_url;
599    task.status = "loading";
600    this.render();
601
602    if (skipUrl) {
603      try {
604        const token = document.querySelector('meta[name="csrf-token"]')?.content;
605        const response = await fetch(skipUrl, {
606          method: task.skipMethod || task.skip_method || "PATCH",
607          headers: {
608            "Content-Type": "application/json",
609            Accept: "application/json",
610            ...(token ? { "X-CSRF-Token": token } : {}),
611          },
612          body: JSON.stringify({
613            task_id: task.id,
614            ignored: skipped,
615            ...(task.skipPayload || task.skip_payload || {}),
616          }),
617        });
618
619        if (!response.ok) throw new Error(`Request failed with ${response.status}`);
620      } catch (error) {
621        task.status = previousStatus;
622        this.render();
623        this.announce(`Could not update ${task.title}`);
624        console.warn("Could not update onboarding checklist task.", error);
625        return;
626      }
627    }
628
629    task.status = skipped ? "skipped" : "pending";
630    this.render();
631    this.announce(skipped ? `${task.title} skipped` : `${task.title} restored`);
632
633    if (skipped && this.isChecklistComplete()) {
634      this.schedule(() => {
635        if (this.isChecklistComplete()) this.handleChecklistComplete();
636      }, 480);
637    }
638  }
639
640  parseData() {
641    try {
642      const parsed = JSON.parse(this.dataValue || "{}");
643      return parsed && typeof parsed === "object" ? parsed : {};
644    } catch (error) {
645      console.warn("Onboarding checklist data must be valid JSON.", error);
646      return { tasks: [] };
647    }
648  }
649
650  render() {
651    const progressTasks = this.tasks.filter(({ status }) => status !== "skipped");
652    const completed = progressTasks.filter(({ status }) => status === "completed").length;
653    const total = progressTasks.length;
654    const percentage = total === 0 ? 0 : Math.round((completed / total) * 100);
655    const allComplete = total > 0 && completed === total;
656    if (!allComplete) {
657      this.completionAnnounced = false;
658      this.dismissing = false;
659    }
660
661    if (this.hasTitleTarget) {
662      this.titleTarget.textContent = this.config.title || "Make this workspace yours";
663    }
664    if (this.panelTitleElement) {
665      this.panelTitleElement.textContent = this.config.panelTitle || this.config.panel_title || "Your quick start";
666    }
667    if (this.panelDescriptionElement) {
668      this.panelDescriptionElement.textContent =
669        this.config.panelDescription || this.config.panel_description || "A few small wins, then you’re flying.";
670    }
671
672    this.progressElement?.setAttribute("aria-label", `${percentage}% complete`);
673    if (this.summaryElement) {
674      this.summaryElement.textContent = allComplete
675        ? this.config.completedSummary || this.config.completed_summary || "All set! Your workspace is ready"
676        : `${completed} of ${total} steps complete`;
677    }
678    if (this.remainingElement) {
679      this.remainingElement.textContent = allComplete ? "Complete" : `${total - completed} remaining`;
680    }
681
682    this.ringElement?.classList.toggle("text-neutral-800", !allComplete);
683    this.ringElement?.classList.toggle("dark:text-neutral-200", !allComplete);
684    this.ringElement?.classList.toggle("text-emerald-500", allComplete);
685    this.progressElement?.classList.toggle("text-neutral-700", !allComplete);
686    this.progressElement?.classList.toggle("dark:text-neutral-200", !allComplete);
687    this.progressElement?.classList.toggle("text-emerald-600", allC
687omplete);
688    this.progressElement?.classList.toggle("dark:text-emerald-400", allComplete);
689
690    this.updateProgress(percentage);
691
692    if (this.demoButtonElement) {
693      this.demoButtonElement.disabled = this.batchRunning;
694      this.demoButtonElement.textContent = allComplete
695        ? "Replay progress"
696        : this.batchRunning
697          ? "Completing…"
698          : "Complete all";
699    }
700
701    this.taskListElement.replaceChildren(...this.tasks.map((task, index) => this.taskElement(task, index)));
702  }
703
704  updateProgress(percentage) {
705    if (!this.ringElement && !this.progressElement) {
706      this.progressInitialized = true;
707      this.currentPercentage = percentage;
708      this.revealData({ animate: false });
709      return;
710    }
711
712    if (this.ringElement) this.ringElement.style.strokeDasharray = "100";
713
714    if (this.progressInitialized) {
715      if (this.ringAnimationFrame !== null) {
716        this.pendingInitialPercentage = percentage;
717      } else {
718        this.applyProgress(percentage);
719      }
720      return;
721    }
722
723    this.progressInitialized = true;
724    this.pendingInitialPercentage = percentage;
725    if (this.ringElement) this.ringElement.style.strokeDashoffset = "100";
726
727    if (this.reducedMotion.matches) {
728      this.applyProgress(percentage);
729      this.revealData({ animate: false });
730      return;
731    }
732
733    this.ringAnimationFrame = window.requestAnimationFrame(() => {
734      this.ringAnimationFrame = window.requestAnimationFrame(() => {
735        this.ringAnimationFrame = null;
736        this.applyProgress(this.pendingInitialPercentage);
737        this.revealData();
738      });
739    });
740  }
741
742  applyProgress(percentage) {
743    if (percentage < 100) this.stopSuccessAnimations();
744
745    if (this.currentPercentage !== percentage && this.progressElement) {
746      this.progressElement.trend = Math.sign(percentage - this.currentPercentage);
747      this.progressElement.update(percentage);
748    }
749    this.currentPercentage = percentage;
750
751    if (this.ringElement) this.ringElement.style.strokeDashoffset = String(100 - percentage);
752  }
753
754  revealData({ animate: shouldAnimate = true } = {}) {
755    this.readyTargets.forEach((element) => element.removeAttribute("aria-hidden"));
756
757    if (!shouldAnimate)
758      return this.readyTargets.forEach((element) => {
759        element.style.opacity = "1";
760      });
761
762    window.requestAnimationFrame(() => {
763      this.readyTargets.forEach((element) => {
764        element.style.opacity = "1";
765      });
766    });
767  }
768
769  configureProgressFlow() {
770    if (!this.progressElement) return;
771
772    this.progressElement.numberSuffix = "%";
773    this.progressElement.plugins = [continuous];
774    this.progressElement.isolate = true;
775    this.progressElement.willChange = true;
776    this.progressElement.respectMotionPreference = true;
777    this.progressElement.spinTiming = {
778      duration: 480,
779      easing: "cubic-bezier(0.65, 0, 0.35, 1)",
780    };
781    this.progressElement.transformTiming = {
782      duration: 480,
783      easing: "cubic-bezier(0.65, 0, 0.35, 1)",
784    };
785    this.progressElement.opacityTiming = {
786      duration: 180,
787      easing: "ease-out",
788    };
789  }
790
791  taskElement(task, index) {
792    const compact = this.compactValue;
793    const item = document.createElement("li");
794    item.className = "group relative will-change-transform";
795    item.dataset.onboardingChecklistTask = "";
796    item.dataset.taskId = task.id;
797
798    if (index > 0) {
799      const divider = document.createElement("span");
800      divider.className = `pointer-events-none absolute ${compact ? "inset-x-4" : "inset-x-5"} top-0 h-px bg-neutral-200/70 dark:bg-white/8`;
801      item.append(divider);
802    }
803
804    const taskUrl = this.safeTaskUrl(task.url);
805    const focusSelector = task.focus || task.focusSelector || task.focus_selector;
806    const actionEvent = task.actionEvent || task.action_event;
807    const interactive = Boolean(taskUrl || focusSelector || actionEvent);
808    const content = document.createElement("div");
809    content.className = compact
810      ? "flex min-h-12 w-full items-center gap-1.5 px-4 py-3"
811      : "flex min-h-[58px] w-full items-center gap-2 px-5 py-3.5";
812
813    const main = document.createElement(taskUrl ? "a" : focusSelector || actionEvent ? "button" : "div");
814    main.className = interactive
815      ? `flex min-w-0 flex-1 items-center ${compact ? "gap-2.5" : "gap-3"} rounded-lg text-left transition-colors duration-150 ease-out hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-neutral-500 motion-reduce:transition-none dark:hover:text-white`
816      : `flex min-w-0 flex-1 items-center ${compact ? "gap-2.5" : "gap-3"}`;
817
818    if (taskUrl) {
819      main.href = taskUrl;
820      if (task.target === "_blank") {
821        main.target = "_blank";
822        main.rel = "noopener noreferrer";
823      }
824    } else if (focusSelector) {
825      main.type = "button";
826      main.dataset.action = "click->onboarding-checklist#focusTask";
827      main.dataset.taskId = task.id;
828    } else if (actionEvent) {
829      main.type = "button";
830      main.dataset.action = "click->onboarding-checklist#dispatchTaskAction";
831      main.dataset.taskId = task.id;
832    }
833
834    if (main instanceof HTMLButtonElement) {
835      main.disabled = task.status === "loading";
836    }
837
838    const status = document.createElement("span");
839    status.className = this.statusClasses(task.status, task);
840    status.setAttribute("aria-hidden", "true");
841    status.append(this.statusIcon(task.status, index, task));
842
843    const copy = document.createElement("span");
844    copy.className = "min-w-0 flex-1";
845
846    const titleRow = document.createElement("span");
847    titleRow.className = "flex min-w-0 items-center gap-1.5";
848
849    const title = document.createElement("span");
850    title.className =
851      task.status === "completed"
852        ? `block truncate ${compact ? "text-xs" : "text-sm"} font-medium text-neutral-400 line-through decoration-neutral-300 dark:text-neutral-500 dark:decoration-neutral-600`
853        : task.status === "skipped"
854          ? `block truncate ${compact ? "text-xs" : "text-sm"} font-medium text-neutral-400 dark:text-neutral-500`
855          : `block truncate ${compact ? "text-xs" : "text-sm"} font-medium text-neutral-900 dark:text-white`;
856    title.textContent = task.title;
857    titleRow.append(title);
858
859    if (task.optional) {
860      const optional = document.createElement("span");
861      optional.className = `inline-flex shrink-0 items-center rounded-md bg-neutral-100 ${compact ? "px-1 py-px text-[8px]" : "px-1.5 py-0.5 text-[9px]"} font-medium text-neutral-500 ring-1 ring-inset ring-neutral-200 dark:bg-white/5 dark:text-neutral-400 dark:ring-white/10`;
862      optional.textContent = "Optional";
863      titleRow.append(optional);
864    }
865
866    const description = document.createElement("span");
867    description.className = `mt-0.5 block truncate ${compact ? "text-[10px]" : "text-xs"} text-neutral-500 dark:text-neutral-400`;
868    description.textContent =
869      task.status === "skipped"
870        ? task.skippedDescription || task.skipped_description || "Skipped for now"
871        : task.description || "";
872    copy.append(titleRow, description);
873
874    main.append(status, copy);
875
876    if (interactive) {
877      const destination = document.createElement("span");
878      destination.className = `flex shrink-0 items-center gap-1 ${compact ? "text-[10px]" : "text-[11px]"} font-semibold text-neutral-500 transition-colors duration-150 group-hover:text-neutral-950 motion-reduce:transition-none dark:text-neutral-400 dark:group-hover:text-white`;
879      if (!compact) destination.append(task.action || (task.status === "completed" ? "View" : "Continue"));
880      destination.append(
881        this.svg(
882          "m6.5 4.5 4.5 4.5-4.5 4.5",
883          `${compact ? "size-3" : "size-3.5"} transition-transform duration-150 ease-out group-hover:translate-x-0.5 motion-reduce:transition-none`,
884          "none",
885          "currentColor",
886        ),
887      );
888      main.append(destination);
889    } else if (task.status !== "completed") {
890      const action = document.createElement("button");
891      action.type = "button";
892      action.dataset.action = "click->onboarding-checklist#complete";
893      action.dataset.taskId = task.id;
894      action.disabled = this.batchRunning || task.status === "loading";
895      action.className = `flex ${compact ? "min-h-7 px-2.5 text-[10px]" : "min-h-9 px-3 text-xs"} shrink-0 items-center justify-center rounded-lg bg-neutral-900 font-medium text-white shadow-sm transition-colors duration-150 ease-out motion-reduce:transition-none hover:bg-neutral-700 disabled:cursor-wait disabled:bg-neutral-200 disabled:text-neutral-500 dark:bg-white dark:text-neutral-950 dar
895k:hover:bg-neutral-200 dark:disabled:bg-neutral-700 dark:disabled:text-neutral-400`;
896      action.textContent = task.status === "loading" ? "Working…" : task.action || "Start";
897      main.append(action);
898    }
899
900    content.append(main);
901
902    if (task.optional && task.status !== "completed" && task.status !== "loading") {
903      const skip = document.createElement("button");
904      skip.type = "button";
905      skip.dataset.action = "click->onboarding-checklist#toggleSkip";
906      skip.dataset.taskId = task.id;
907      skip.className = `${compact ? "min-h-7 px-1.5 text-[9px]" : "min-h-8 px-2 text-[10px]"} shrink-0 rounded-md font-medium text-neutral-400 transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 motion-reduce:transition-none dark:text-neutral-500 dark:hover:bg-white/8 dark:hover:text-neutral-200`;
908      skip.textContent = task.status === "skipped" ? "Restore" : "Skip";
909      content.append(skip);
910    }
911
912    item.append(content);
913    return item;
914  }
915
916  safeTaskUrl(value) {
917    if (typeof value !== "string" || value.trim() === "") return null;
918
919    try {
920      const url = new URL(value, window.location.origin);
921      return ["http:", "https:"].includes(url.protocol) ? value : null;
922    } catch (_error) {
923      return null;
924    }
925  }
926
927  statusClasses(status, task = {}) {
928    const base = `flex ${this.compactValue ? "size-6" : "size-7"} shrink-0 items-center justify-center rounded-full will-change-transform`;
929    if (status === "completed")
930      return `${base} bg-emerald-500 text-white shadow-[0_1px_3px_rgb(16_185_129_/_25%)] border border-emerald-600 dark:border-emerald-400`;
931    if (status === "loading")
932      return `${base} bg-amber-50 text-amber-600 ring-1 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-300 dark:ring-amber-400/20`;
933    if (status === "skipped")
934      return `${base} bg-neutral-50 text-neutral-400 ring-1 ring-inset ring-neutral-200 dark:bg-white/5 dark:text-neutral-500 dark:ring-white/10`;
935    if (task.icon === "chrome")
936      return `${base} bg-neutral-900 text-white shadow-sm ring-1 ring-inset ring-black/10 dark:bg-white dark:text-neutral-950 dark:ring-white/10`;
937    return `${base} bg-neutral-100 text-neutral-500 ring-1 ring-inset ring-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:ring-neutral-700`;
938  }
939
940  statusIcon(status, index, task = {}) {
941    if (status === "completed") {
942      const icon = this.svg("M5 9.5 8 12.5 14 6.5", this.compactValue ? "size-3.5" : "size-4", "none", "currentColor");
943      icon.querySelector("path")?.setAttribute("pathLength", "1");
944      return icon;
945    }
946
947    if (status === "loading") {
948      const spinner = this.loadingSpinner();
949      spinner.setAttribute("aria-label", "Loading");
950      return spinner;
951    }
952
953    if (status === "skipped") {
954      return this.svg("M5.25 9h7.5", this.compactValue ? "size-3" : "size-3.5", "none", "currentColor");
955    }
956
957    const taskIcon = this.taskIcon(task);
958    if (taskIcon) return taskIcon;
959
960    const number = document.createElement("span");
961    number.className = `${this.compactValue ? "text-[10px]" : "text-[11px]"} font-semibold tabular-nums`;
962    number.textContent = String(index + 1);
963    return number;
964  }
965
966  taskIcon(task) {
967    if (task.icon === "chrome") return this.chromeIcon();
968
969    const customPaths = task.iconPaths || task.icon_paths || task.iconPath || task.icon_path;
970    if (customPaths) {
971      return this.pathsIcon(Array.isArray(customPaths) ? customPaths : [customPaths], {
972        viewBox: task.iconViewBox || task.icon_view_box || "0 0 18 18",
973        filled: task.iconFill || task.icon_fill,
974      });
975    }
976
977    const icons = {
978      user: ["M9 9.25a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-5.25 5.5c.35-2.35 2.42-4 5.25-4s4.9 1.65 5.25 4"],
979      profile: ["M9 9.25a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-5.25 5.5c.35-2.35 2.42-4 5.25-4s4.9 1.65 5.25 4"],
980      folder: [
981        "M2.75 6.5V5.25c0-.83.67-1.5 1.5-1.5h2.4l1.4 1.75h5.7c.83 0 1.5.67 1.5 1.5v6.25c0 .83-.67 1.5-1.5 1.5h-9.5c-.83 0-1.5-.67-1.5-1.5V6.5Zm0 0h12.5",
982      ],
983      workspace: [
984        "M2.75 6.5V5.25c0-.83.67-1.5 1.5-1.5h2.4l1.4 1.75h5.7c.83 0 1.5.67 1.5 1.5v6.25c0 .83-.67 1.5-1.5 1.5h-9.5c-.83 0-1.5-.67-1.5-1.5V6.5Zm0 0h12.5",
985      ],
986      users: [
987        "M7.25 8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm-4 6c.3-2.25 1.75-3.75 4-3.75s3.7 1.5 4 3.75m.25-10.6a2.5 2.5 0 0 1 0 4.7m1.25 2.15c1.2.55 1.85 1.75 2 3.75",
988      ],
989      sparkles: [
990        "m8.75 2 .65 2.1a4.9 4.9 0 0 0 3.25 3.25l2.1.65-2.1.65A4.9 4.9 0 0 0 9.4 11.9L8.75 14l-.65-2.1a4.9 4.9 0 0 0-3.25-3.25L2.75 8l2.1-.65A4.9 4.9 0 0 0 8.1 4.1L8.75 2Zm5.1 9.75.3.95c.2.65.7 1.15 1.35 1.35l.95.3-.95.3c-.65.2-1.15.7-1.35 1.35l-.3.95-.3-.95a2.1 2.1 0 0 0-1.35-1.35l-.95-.3.95-.3a2.1 2.1 0 0 0 1.35-1.35l.3-.95Z",
991      ],
992      bookmark: ["M5 3.25h8c.69 0 1.25.56 1.25 1.25v10.25L9 11.6l-5.25 3.15V4.5C3.75 3.81 4.31 3.25 5 3.25Z"],
993      bell: ["M4.25 12.5h9.5l-1.15-1.65V8a3.6 3.6 0 0 0-7.2 0v2.85L4.25 12.5Zm3.1 2a1.75 1.75 0 0 0 3.3 0"],
994      link: [
995        "M7.2 10.8 10.8 7.2m-5.05 5.05-1.1 1.1a2.12 2.12 0 1 1-3-3l2.1-2.1a2.12 2.12 0 0 1 3 0m4.5 1.5a2.12 2.12 0 0 0 3 0l2.1-2.1a2.12 2.12 0 1 0-3-3l-1.1 1.1",
996      ],
997      keyboard: [
998        "M3.25 4.25h11.5c.83 0 1.5.67 1.5 1.5v6.5c0 .83-.67 1.5-1.5 1.5H3.25c-.83 0-1.5-.67-1.5-1.5v-6.5c0-.83.67-1.5 1.5-1.5Zm1.25 3h.01m2.49 0h.01m2.49 0h.01m2.49 0h.01m-7 3h7",
999      ],
1000    };
1001
1002    return icons[task.icon] ? this.pathsIcon(icons[task.icon]) : null;
1003  }
1004
1005  pathsIcon(paths, { viewBox = "0 0 18 18", filled = false } = {}) {
1006    const namespace = "http://www.w3.org/2000/svg";
1007    const icon = document.createElementNS(namespace, "svg");
1008    icon.setAttribute("viewBox", viewBox);
1009    icon.setAttribute("class", this.compactValue ? "size-3" : "size-3.5");
1010    icon.setAttribute("fill", filled ? "currentColor" : "none");
1011    icon.setAttribute("stroke", filled ? "none" : "currentColor");
1012    icon.setAttribute("stroke-width", "1.5");
1013    icon.setAttribute("stroke-linecap", "round");
1014    icon.setAttribute("stroke-linejoin", "round");
1015
1016    paths.filter(Boolean).forEach((pathData) => {
1017      const path = document.createElementNS(namespace, "path");
1018      path.setAttribute("d", pathData);
1019      icon.append(path);
1020    });
1021
1022    return icon;
1023  }
1024
1025  chromeIcon() {
1026    const namespace = "http://www.w3.org/2000/svg";
1027    const icon = document.createElementNS(namespace, "svg");
1028    icon.setAttribute("viewBox", "0 0 32 32");
1029    icon.setAttribute("class", "size-3.5");
1030    icon.setAttribute("fill", "currentColor");
1031
1032    [
1033      "M9.138 14.616C9.783 11.417 12.614 9 16 9h12.106C25.681 4.823 21.169 2 16 2 11.522 2 7.539 4.121 4.974 7.403l4.164 7.213Z",
1034      "M18.236 22.627A6.99 6.99 0 0 1 16 23a6.997 6.997 0 0 1-6.055-3.504l-.008.004-6.052-10.483A13.925 13.925 0 0 0 2 16c0 7.061 5.261 12.903 12.065 13.85l4.171-7.223Z",
1035      "M20.615 10.75A6.983 6.983 0 0 1 23 16a6.96 6.96 0 0 1-.945 3.496l.008.004L16 30c7.719 0 14-6.281 14-14 0-1.857-.371-3.627-1.031-5.25h-8.354Z",
1036    ].forEach((pathData) => {
1037      const path = document.createElementNS(namespace, "path");
1038      path.setAttribute("d", pathData);
1039      icon.append(path);
1040    });
1041
1042    const circle = document.createElementNS(namespace, "circle");
1043    circle.setAttribute("cx", "16");
1044    circle.setAttribute("cy", "16");
1045    circle.setAttribute("r", "5.25");
1046    icon.append(circle);
1047    return icon;
1048  }
1049
1050  loadingSpinner() {
1051    const namespace = "http://www.w3.org/2000/svg";
1052    const spinner = document.createElementNS(namespace, "svg");
1053    spinner.setAttribute("viewBox", "0 0 18 18");
1054    spinner.setAttribute(
1055      "class",
1056      `${this.compactValue ? "size-3.5" : "size-4"} animate-spin rounded-full motion-reduce:animate-none`,
1057    );
1058    spinner.setAttribute("fill", "currentColor");
1059
1060    const track = document.createElementNS(namespace, "path");
1061    track.setAttribute("d", "M9 17a8 8 0 1 1 0-16 8 8 0 0 1 0 16Zm0-14.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z");
1062    track.setAttribute("opacity", "0.32");
1063
1064    const lead = document.createElementNS(namespace, "path");
1065    lead.setAttribute(
1066      "d",
1067      "M16.25 9.75a.75.75 0 0 1-.75-.75A6.5 6.5 0 0 0 9 2.5.75.75 0 0 1 9 1a8 8 0 0 1 8 8 .75.75 0 0 1-.75.75Z",
1068    );
1069
1070    spinner.append(track, lead);
1071    return spinner;
1072  }
1073
1074  svg(pathData, className, fill, stroke) {
1075    const namespace = "http://www.w3.org/2000/svg";
1076    const svg = document.createElementNS(namespace, "svg");
1077    svg.setAttribute("viewBox", "0 0 18 18");
1078    svg.setAttribute("class", className);
1079    svg.setAttribute("fill", fill);
1080    svg.setAttribute("stroke", stroke);
1081    svg.setAttribute("stroke-width", "1.8");
1082    svg.setAttribute("stroke-linecap", "round");
1083    svg.setAttribute("stroke-linejoin", "round");
1084
1085    const path = document.createElementNS(namespace, "path");
1086    path.setAttribute("d", pathData);
1087    svg.append(path);
1088    return svg;
1089  }
1090
1091  applyMode() {
1092    const mode = this.normalizedMode();
1093    const overlayAbove = mode === "overlay-above";
1094    const overlayBelow = mode === "overlay-below";
1095    const overlay = overlayAbove || overlayBelow;
1096
1097    // Match the dropdown portal: document-relative absolute positioning lets
1098    // normal page scrolling move the anchor and panel together without the
1099    // panel visually chasing the anchor on every scroll event.
1100    this.panelElement.classList.toggle("absolute", overlay);
1101    this.panelElement.classList.toggle("mt-2.5", !overlay);
1102    this.panelElement.classList.toggle("origin-bottom", overlayAbove);
1103    this.panelElement.classList.toggle("origin-top", overlayBelow || !overlay);
1104    this.setSurfaceTransitionDuration(this.panelElement, {
1105      opening: this.openValue,
1106      overlay,
1107    });
1108
1109    if (overlay && this.openValue) {
1110      this.mountPanelForOverlay();
1111      this.startOverlayPositioning();
1112    } else if (!overlay) {
1113      this.stopOverlayPositioning();
1114      this.restorePanelMount();
1115      this.clearOverlayPositionStyles();
1116    }
1117
1118    this.modeButtonTargets.forEach((button) => {
1119      const active = button.dataset.mode === mode;
1120      button.setAttribute("aria-pressed", String(active));
1121      button.classList.toggle("bg-white", active);
1122      button.classList.toggle("text-neutral-900", active);
1123      button.classList.toggle("shadow-sm", active);
1124      button.classList.toggle("dark:bg-neutral-700", active);
1125      button.classList.toggle("dark:text-white", active);
1126    });
1127  }
1128
1129  normalizedMode() {
1130    return this.modeValue === "overlay" ? "overlay-above" : this.modeValue;
1131  }
1132
1133  isOverlayMode() {
1134    return ["overlay-above", "overlay-below"].includes(this.normalizedMode());
1135  }
1136
1137  isOverlayBelow() {
1138    return this.normalizedMode() === "overlay-below";
1139  }
1140
1141  mountPanelForOverlay() {
1142    const appendTarget = this.element.closest("dialog[open]") || document.body;
1143    if (this.panelElement.parentElement !== appendTarget) appendTarget.appendChild(this.panelElement);
1144    this.isPanelPortaled = this.panelElement.parentElement !== this.originalPanelParent;
1145  }
1146
1147  restorePanelMount() {
1148    if (!this.isPanelPortaled || !this.originalPanelParent) return;
1149
1150    if (this.originalPanelNextSibling?.parentNode === this.originalPanelParent) {
1151      this.originalPanelParent.insertBefore(this.panelElement, this.originalPanelNextSibling);
1152    } else {
1153      this.originalPanelParent.appendChild(this.panelElement);
1154    }
1155
1156    this.isPanelPortaled = false;
1157  }
1158
1159  startOverlayPositioning() {
1160    this.stopOverlayPositioning();
1161    this.overlayPositionCleanup = autoUpdate(this.surfaceElement, this.panelElement, () => this.positionOverlay());
1162  }
1163
1164  stopOverlayPositioning() {
1165    this.overlayPositionCleanup?.();
1166    this.overlayPositionCleanup = null;
1167  }
1168
1169  async positionOverlay() {
1170    if (!this.openValue || !this.isOverlayMode()) return;
1171
1172    const anchorWidth = this.surfaceElement.getBoundingClientRect().width;
1173    this.panelElement.style.width = `${anchorWidth}px`;
1174
1175    const { x, y } = await computePosition(this.surfaceElement, this.panelElement, {
1176      placement: this.isOverlayBelow() ? "bottom" : "top",
1177      middleware: [offset(10), shift({ padding: 12 })],
1178    });
1179
1180    if (!this.openValue || !this.isOverlayMode()) return;
1181    Object.assign(this.panelElement.style, { left: `${x}px`, top: `${y}px` });
1182  }
1183
1184  clearOverlayPositionStyles() {
1185    this.panelElement.style.width = "";
1186    this.panelElement.style.left = "";
1187    this.panelElement.style.top = "";
1188    this.panelElement.style.visibility = "";
1189  }
1190
1191  paintOpenState({ animate: shouldAnimate = true } = {}) {
1192    const sequence = ++this.panelOpenSequence;
1193    const open = this.openValue;
1194    const animateChange = shouldAnimate && !this.reducedMotion.matches;
1195    const wasHidden = this.panelElement.hidden;
1196
1197    this.stopPanelAnimations();
1198
1199    this.triggerElement?.setAttribute("aria-expanded", String(open));
1200    if (this.chevronElement) {
1201      this.setSurfaceTransitionDuration(this.chevronElement, { opening: open });
1202      this.chevronElement.style.transform = open ? "rotate(180deg)" : "rotate(0deg)";
1203    }
1204    this.panelElement.toggleAttribute("inert", !open);
1205
1206    if (open) {
1207      if (this.isOverlayMode()) {
1208        this.prepareOverlayOpen({ wasHidden, animateChange, sequence });
1209      } else {
1210        this.panelElement.hidden = false;
1211        if (animateChange) {
1212          this.animateIn({ wasHidden });
1213        } else {
1214          this.panelElement.style.height = "auto";
1215          this.panelElement.style.opacity = "1";
1216          this.panelElement.style.transform = "translateY(0)";
1217        }
1218      }
1219    } else if (this.panelElement.style.visibility === "hidden") {
1220      this.panelElement.hidden = true;
1221      this.panelElement.style.height = "";
1222      this.panelElement.style.opacity = "";
1223      this.panelElement.style.transform = "";
1224      this.stopOverlayPositioning();
1225      this.restorePanelMount();
1226      this.clearOverlayPositionStyles();
1227    } else if (animateChange && !this.panelElement.hidden) {
1228      this.animateOut();
1229    } else {
1230      this.panelElement.hidden = true;
1231      this.panelElement.style.height = "";
1232      this.panelElement.style.opacity = "";
1233      this.panelElement.style.transform = "";
1234      this.stopOverlayPositioning();
1235      this.restorePanelMount();
1236      this.clearOverlayPositionStyles();
1237    }
1238  }
1239
1240  animateIn({ wasHidden }) {
1241    if (this.isOverlayMode()) {
1242      this.panelAnimation = this.transitionOverlayPanel({ opening: true, wasHidden });
1243    } else {
1244      this.panelAnimation = this.transitionInlinePanel({ opening: true, wasHidden });
1245
1246      this.panelAnimation.finished.then(() => {
1247        if (this.openValue && this.modeValue === "inline") this.panelElement.style.height = "auto";
1248      });
1249    }
1250  }
1251
1252  animateOut() {
1253    this.panelAnimation = this.isOverlayMode()
1254      ? this.transitionOverlayPanel({ opening: false })
1255      : this.transitionInlinePanel({ opening: false });
1256
1257    this.panelAnimation.finished.then(() => {
1258      if (!this.openValue) {
1259        this.panelElement.hidden = true;
1260        this.panelElement.style.height = "";
1261        this.panelElement.style.opacity = "";
1262        this.panelElement.style.transform = "";
1263        this.stopOverlayPositioning();
1264        this.restorePanelMount();
1265        this.clearOverlayPositionStyles();
1266      }
1267    });
1268  }
1269
1270  celebrate(taskId) {
1271    if (this.reducedMotion.matches) {
1272      if (this.isChecklistComplete()) this.handleChecklistComplete();
1273      return;
1274    }
1275
1276    this.completionAnimations.forEach((animation) => animation.stop?.());
1277
1278    const completedStatus = this.taskListElement.querySelector(
1279      `[data-task-id="${CSS.escape(String(taskId))}"] .bg-emerald-500`,
1280    );
1281    if (completedStatus) {
1282      const checkmarkPath = completedStatus.querySelector("svg path");
1283      const statusAnimation = this.animateElement(
1284        completedStatus,
1285        [
1286          { opacity: 0.25, transform: "scale(0.86)", offset: 0 },
1287          { opacity: 1, transform: "scale(1.04)", offset: 0.58 },
1288          { opacity: 1, transform: "scale(0.995)", offset: 0.82 },
1289          { opacity: 1, transform: "scale(1)", offset: 1 },
1290        ],
1291        {
1292          duration: 680,
1293          easing: "cubic-bezier(0.22, 1, 0.36, 1)",
1294        },
1295      );
1296
1297      const checkmarkAnimation =
1298        checkmarkPath &&
1299        this.animateElement(
1300          checkmarkPath,
1301          [
1302            { opacity: 0, strokeDasharray: "1 1", strokeDashoffset: 1 },
1303            { opacity: 1, strokeDasharray: "1 1", strokeDashoffset: 0 },
1304          ],
1305          {
1306            delay: 150,
1307            duration: 460,
1308            easing: "cubic-bezier(0.22, 1, 0.36, 1)",
1309            fill: "both",
1310          },
1311        );
1312
1313      this.completionAnimations = [statusAnimation, checkmarkAnimation].filter(
1313Boolean);
1314    } else {
1315      this.completionAnimations = [];
1316    }
1317
1318    if (this.isChecklistComplete()) {
1319      this.schedule(() => {
1320        if (this.isChecklistComplete()) {
1321          this.handleChecklistComplete();
1322        }
1323      }, 480);
1324    }
1325  }
1326
1327  isChecklistComplete() {
1328    const progressTasks = this.tasks.filter(({ status }) => status !== "skipped");
1329    return progressTasks.length > 0 && progressTasks.every(({ status }) => status === "completed");
1330  }
1331
1332  handleChecklistComplete() {
1333    if (this.completionAnnounced || !this.isChecklistComplete()) return;
1334
1335    this.completionAnnounced = true;
1336    const eventName = this.config.completedEvent || this.config.completed_event || "onboarding-checklist:completed";
1337
1338    this.element.dispatchEvent(
1339      new CustomEvent(eventName, {
1340        bubbles: true,
1341        detail: {
1342          checklistId: this.checklistId(),
1343          checklist_id: this.checklistId(),
1344          tasks: this.cloneTasks(this.tasks),
1345        },
1346      }),
1347    );
1348
1349    if (!this.reducedMotion.matches) this.celebrateChecklist();
1350
1351    if (this.config.dismissOnComplete || this.config.dismiss_on_complete) {
1352      const delay = Number(this.config.dismissDelay ?? this.config.dismiss_delay ?? 1200);
1353      this.schedule(
1354        () => {
1355          if (this.isChecklistComplete()) this.dismissChecklist();
1356        },
1357        Math.max(0, delay),
1358      );
1359    }
1360  }
1361
1362  dismissChecklist() {
1363    if (this.dismissing) return;
1364
1365    this.dismissing = true;
1366    this.openValue = false;
1367    this.paintOpenState({ animate: false });
1368    this.element.style.overflow = "hidden";
1369
1370    const remove = () => this.element.remove();
1371    if (this.reducedMotion.matches) {
1372      remove();
1373      return;
1374    }
1375
1376    const duration = Number(this.config.dismissDuration ?? this.config.dismiss_duration ?? 420);
1377    const animation = this.animateElement(
1378      this.element,
1379      [
1380        { opacity: 1, transform: "translateY(0)", height: `${this.element.offsetHeight}px` },
1381        { opacity: 0, transform: "translateY(-6px)", height: "0px" },
1382      ],
1383      {
1384        duration: Math.max(0, duration),
1385        easing: "cubic-bezier(0.35, 0.75, 0, 1)",
1386      },
1387    );
1388
1389    animation.finished.then(remove);
1390  }
1391
1392  celebrateChecklist() {
1393    this.stopSuccessAnimations();
1394
1395    const haloAnimation =
1396      this.completionHaloElement &&
1397      this.animateElement(
1398        this.completionHaloElement,
1399        {
1400          opacity: [0, 0.52, 0.2, 0],
1401          transform: ["scale(0.68)", "scale(1.55)", "scale(1.95)", "scale(2.15)"],
1402        },
1403        { duration: 1050, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
1404      );
1405
1406    const progressAnimation =
1407      this.progressVisualElement &&
1408      this.animateElement(
1409        this.progressVisualElement,
1410        {
1411          transform: ["scale(1)", "scale(1.1)", "scale(1.1)", "scale(0.985)", "scale(1)"],
1412        },
1413        { duration: 760, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
1414      );
1415
1416    const particleVectors = [
1417      { x: -18, y: -20, color: "bg-emerald-400", delay: 0 },
1418      { x: 1, y: -25, color: "bg-neutral-400", delay: 0.03 },
1419      { x: 19, y: -18, color: "bg-emerald-400", delay: 0.06 },
1420      { x: 25, y: 1, color: "bg-neutral-400", delay: 0.09 },
1421      { x: 17, y: 20, color: "bg-emerald-400", delay: 0.12 },
1422      { x: -2, y: 25, color: "bg-neutral-400", delay: 0.15 },
1423      { x: -21, y: 17, color: "bg-emerald-400", delay: 0.18 },
1424      { x: -25, y: -3, color: "bg-neutral-400", delay: 0.21 },
1425    ];
1426
1427    const particleAnimations = this.progressVisualElement
1428      ? particleVectors.map(({ x, y, color, delay }) => {
1429          const particle = document.createElement("span");
1430          particle.className = `pointer-events-none absolute left-1/2 top-1/2 size-[3px] rounded-full ${color} will-change-transform`;
1431          particle.setAttribute("aria-hidden", "true");
1432          this.progressVisualElement.append(particle);
1433          this.successParticles.push(particle);
1434
1435          const animation = this.animateElement(
1436            particle,
1437            {
1438              opacity: [0, 0.9, 0],
1439              transform: [
1440                "translate(-50%, -50%) scale(0.5)",
1441                `translate(calc(-50% + ${x}px), calc(-50% + ${y}px)) scale(1.15)`,
1442                `translate(calc(-50% + ${x * 1.18}px), calc(-50% + ${y * 1.18}px)) scale(0.6)`,
1443              ],
1444            },
1445            {
1446              delay: delay * 1000,
1447              duration: 860,
1448              easing: "cubic-bezier(0.22, 1, 0.36, 1)",
1449            },
1450          );
1451
1452          animation.finished.then(() => {
1453            particle.remove();
1454            this.successParticles = this.successParticles.filter((element) => element !== particle);
1455          });
1456
1457          return animation;
1458        })
1459      : [];
1460
1461    this.successAnimations = [haloAnimation, progressAnimation, ...particleAnimations].filter(
1461Boolean);
1462  }
1463
1464  stopSuccessAnimations() {
1465    this.successAnimations.forEach((animation) => animation.stop?.());
1466    this.successParticles.forEach((particle) => particle.remove());
1467    this.successAnimations = [];
1468    this.successParticles = [];
1469
1470    if (this.completionHaloElement) {
1471      this.completionHaloElement.style.opacity = "0";
1472      this.completionHaloElement.style.transform = "";
1473    }
1474
1475    if (this.progressVisualElement) {
1476      this.progressVisualElement.style.transform = "";
1477    }
1478  }
1479
1480  stopPanelAnimations() {
1481    this.panelAnimation?.stop?.();
1482    this.panelAnimation = null;
1483  }
1484
1485  stopAnimations() {
1486    this.stopPanelAnimations();
1487    this.completionAnimations.forEach((animation) => animation.stop?.());
1488    this.stopSuccessAnimations();
1489    this.completionAnimations = [];
1490  }
1491
1492  announce(message) {
1493    if (this.liveRegionElement) this.liveRegionElement.textContent = message;
1494  }
1495};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.