), inputs, textareas, the search overlay, or the Kapa modal.
+ */
+export function isInsideExcludedElement(node) {
+ let current = node;
+ while (current) {
+ if (current.nodeType === 1) {
+ const tag = current.tagName;
+ if (tag === 'PRE' || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'CODE') {
+ return true;
+ }
+ // Kapa modal or search overlay
+ if (
+ current.classList &&
+ (current.classList.contains('kapa-modal') ||
+ current.classList.contains('DocSearch-Modal') ||
+ current.id === 'feedback-comment')
+ ) {
+ return true;
+ }
+ // Our own feedback widget
+ if (current.getAttribute('aria-label') === 'Page feedback') {
+ return true;
+ }
+ }
+ current = current.parentElement;
+ }
+ return false;
+}
diff --git a/docusaurus/src/components/PageFeedback/ThankYou.jsx b/docusaurus/src/components/PageFeedback/ThankYou.jsx
new file mode 100644
index 0000000000..07b1c563a5
--- /dev/null
+++ b/docusaurus/src/components/PageFeedback/ThankYou.jsx
@@ -0,0 +1,53 @@
+import React from 'react';
+
+function buildGitHubIssueUrl({ pagePath, pageTitle, comment, selectionText }) {
+ const title = `[Doc feedback] ${pageTitle}`;
+ const body = [
+ `**Page:** [${pageTitle}](https://docs.strapi.io${pagePath})`,
+ selectionText ? `\n**Selected text:**\n> ${selectionText}` : null,
+ '',
+ '**Feedback:**',
+ comment,
+ '',
+ '',
+ ].filter(Boolean).join('\n');
+
+ const params = new URLSearchParams({
+ template: 'doc-feedback.yml',
+ title,
+ body,
+ labels: 'feedback: from-docs-widget',
+ });
+
+ return `https://github.com/strapi/documentation/issues/new?${params.toString()}`;
+}
+
+export default function ThankYou({ vote, pagePath, pageTitle, comment, selectionText }) {
+ return (
+
+
+
+
+ {vote === 'up'
+ ? 'Thanks for your feedback!'
+ : 'Thanks for letting us know. We\'ll look into it.'}
+
+
+ {vote === 'down' && comment && (
+
+
+ {' '}Create a GitHub issue
+
+ )}
+
+ );
+}
diff --git a/docusaurus/src/components/PageFeedback/api.js b/docusaurus/src/components/PageFeedback/api.js
new file mode 100644
index 0000000000..0285f20159
--- /dev/null
+++ b/docusaurus/src/components/PageFeedback/api.js
@@ -0,0 +1,28 @@
+/**
+ * Submit feedback to the n8n webhook backend.
+ *
+ * Returns the created feedback ID on success, or throws on failure.
+ * The endpoint URL is configured once here so swapping backends
+ * requires changing only this file.
+ */
+
+const FEEDBACK_ENDPOINT = 'https://n8n.tools.strapi.team/webhook/docs-feedback';
+
+export async function submitFeedback(payload) {
+ const response = await fetch(FEEDBACK_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Feedback-Source': 'docs-widget',
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Feedback submission failed (${response.status}): ${text}`);
+ }
+
+ const data = await response.json();
+ return data.id;
+}
diff --git a/docusaurus/src/components/PageFeedback/config.js b/docusaurus/src/components/PageFeedback/config.js
new file mode 100644
index 0000000000..e82be64bbd
--- /dev/null
+++ b/docusaurus/src/components/PageFeedback/config.js
@@ -0,0 +1,5 @@
+/**
+ * Kill switch for the feedback widget.
+ * Set to false to hide the widget on all pages.
+ */
+export const FEEDBACK_ENABLED = true;
diff --git a/docusaurus/src/components/PageFeedback/index.jsx b/docusaurus/src/components/PageFeedback/index.jsx
new file mode 100644
index 0000000000..5435e98e39
--- /dev/null
+++ b/docusaurus/src/components/PageFeedback/index.jsx
@@ -0,0 +1,142 @@
+import React, { useState, useCallback, useImperativeHandle, forwardRef } from 'react';
+import FeedbackForm from './FeedbackForm';
+import ThankYou from './ThankYou';
+import { submitFeedback } from './api';
+import { FEEDBACK_ENABLED } from './config';
+import styles from './styles.module.scss';
+
+const PageFeedback = forwardRef(function PageFeedback({ pagePath, pageId, pageTitle }, ref) {
+ if (!FEEDBACK_ENABLED) return null;
+ const [stage, setStage] = useState('initial'); // initial | form | submitting | done | error
+ const [vote, setVote] = useState(null);
+ const [lastComment, setLastComment] = useState(null);
+ const [selectionData, setSelectionData] = useState(null); // L2: { kind, selection }
+
+ const handleVote = useCallback((value) => {
+ setVote(value);
+ setSelectionData(null);
+ setStage('form');
+ }, []);
+
+ const handleSelectionFeedback = useCallback((data) => {
+ setVote('down');
+ setSelectionData(data);
+ setStage('form');
+ document.querySelector('[aria-label="Page feedback"]')?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'center',
+ });
+ }, []);
+
+ // Expose handleSelectionFeedback to the footer wrapper via ref
+ useImperativeHandle(ref, () => ({
+ onSelectionFeedback: handleSelectionFeedback,
+ }), [handleSelectionFeedback]);
+
+ const doSubmit = useCallback(
+ async (comment, hp) => {
+ setLastComment(comment);
+ setStage('submitting');
+ try {
+ await submitFeedback({
+ kind: selectionData?.kind || 'page',
+ vote,
+ comment: comment || undefined,
+ pagePath,
+ pageId,
+ pageTitle,
+ selection: selectionData?.selection || undefined,
+ _hp: hp || undefined,
+ });
+ setStage('done');
+ } catch {
+ setStage('error');
+ }
+ },
+ [vote, pagePath, pageId, pageTitle, selectionData],
+ );
+
+ const handleCancel = useCallback(() => {
+ setSelectionData(null);
+ setStage('initial');
+ }, []);
+
+ return (
+
+ {stage === 'initial' && (
+
+
+ Was this page helpful?
+
+
+
+
+
+
+ )}
+
+ {stage === 'form' && (
+ <>
+ {selectionData && (
+
+
+
+ {selectionData.selection?.text?.slice(0, 100)}
+ {selectionData.selection?.text?.length > 100 ? '...' : ''}
+
+
+ )}
+
+ >
+ )}
+
+ {stage === 'submitting' && (
+
+ Sending feedback...
+
+ )}
+
+ {stage === 'done' && (
+
+ )}
+
+ {stage === 'error' && (
+
+ Something went wrong. Please try again.
+
+
+ )}
+
+
+ );
+});
+
+export default PageFeedback;
diff --git a/docusaurus/src/components/PageFeedback/styles.module.scss b/docusaurus/src/components/PageFeedback/styles.module.scss
new file mode 100644
index 0000000000..5b00cf6cf6
--- /dev/null
+++ b/docusaurus/src/components/PageFeedback/styles.module.scss
@@ -0,0 +1,303 @@
+@use '../../scss/_mixins.scss' as *;
+
+.pageFeedback {
+ margin-top: 4rem;
+ margin-bottom: var(--strapi-spacing-4, 1rem);
+ padding: var(--strapi-spacing-4, 1rem) var(--strapi-spacing-5, 1.5rem);
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 8px;
+ background: var(--ifm-background-surface-color, #fff);
+ text-align: center;
+
+ @include dark {
+ .pageFeedback {
+ background: var(--ifm-background-surface-color, #1e1e1e);
+ }
+ }
+}
+
+.pageFeedback__prompt {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--strapi-spacing-3, 0.75rem);
+ flex-wrap: wrap;
+}
+
+.pageFeedback__question {
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: var(--ifm-font-color-base);
+}
+
+.pageFeedback__buttons {
+ display: flex;
+ gap: var(--strapi-spacing-2, 0.5rem);
+}
+
+.pageFeedback__voteButton {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 8px;
+ background: transparent;
+ cursor: pointer;
+ font-size: 1.25rem;
+ color: var(--ifm-font-color-secondary);
+ @include transition;
+
+ &:hover {
+ border-color: var(--ifm-color-primary);
+ color: var(--ifm-color-primary);
+ background: var(--ifm-color-primary-lightest, rgba(99, 91, 255, 0.05));
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--ifm-color-primary);
+ outline-offset: 2px;
+ }
+}
+
+/* Form (shown after voting) */
+:global {
+ .pageFeedback__form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--strapi-spacing-3, 0.75rem);
+ text-align: left;
+ }
+
+ .pageFeedback__formLabel {
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: var(--ifm-font-color-base);
+ }
+
+ .pageFeedback__textarea {
+ width: 100%;
+ min-height: 80px;
+ padding: var(--strapi-spacing-2, 0.5rem) var(--strapi-spacing-3, 0.75rem);
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 6px;
+ background: var(--ifm-background-color);
+ color: var(--ifm-font-color-base);
+ font-family: inherit;
+ font-size: 0.9rem;
+ resize: vertical;
+
+ &:focus {
+ border-color: var(--ifm-color-primary);
+ outline: none;
+ box-shadow: 0 0 0 2px var(--ifm-color-primary-lightest, rgba(99, 91, 255, 0.15));
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+ }
+
+ .pageFeedback__formActions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--strapi-spacing-2, 0.5rem);
+ }
+
+ .pageFeedback__charCount {
+ font-size: 0.8rem;
+ color: var(--ifm-font-color-secondary);
+ }
+
+ .pageFeedback__formButtons {
+ display: flex;
+ gap: var(--strapi-spacing-2, 0.5rem);
+ }
+
+ .pageFeedback__cancelButton {
+ padding: var(--strapi-spacing-1, 0.25rem) var(--strapi-spacing-3, 0.75rem);
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--ifm-font-color-secondary);
+ cursor: pointer;
+ font-size: 0.85rem;
+
+ &:hover {
+ border-color: var(--ifm-font-color-secondary);
+ }
+ }
+
+ .pageFeedback__submitButton {
+ padding: var(--strapi-spacing-1, 0.25rem) var(--strapi-spacing-3, 0.75rem);
+ border: none;
+ border-radius: 6px;
+ background: var(--ifm-color-primary);
+ color: #fff;
+ cursor: pointer;
+ font-size: 0.85rem;
+ font-weight: 500;
+
+ &:hover:not(:disabled) {
+ background: var(--ifm-color-primary-dark);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ .pageFeedback__thankYou {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--strapi-spacing-2, 0.5rem);
+ padding: var(--strapi-spacing-2, 0.5rem) 0;
+ }
+
+ .pageFeedback__thankYouRow {
+ display: flex;
+ align-items: center;
+ gap: var(--strapi-spacing-2, 0.5rem);
+
+ i {
+ font-size: 1.2rem;
+ }
+ }
+
+ .pageFeedback__thankYouText {
+ margin: 0;
+ font-size: 0.95rem;
+ color: var(--ifm-font-color-base);
+ }
+
+ .pageFeedback__issueLink {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--strapi-spacing-1, 0.25rem);
+ margin-top: var(--strapi-spacing-1, 0.25rem);
+ padding: var(--strapi-spacing-1, 0.25rem) var(--strapi-spacing-3, 0.75rem);
+ border: 1px solid var(--ifm-toc-border-color);
+ border-radius: 6px;
+ font-size: 0.85rem;
+ color: var(--ifm-font-color-secondary);
+ text-decoration: none;
+
+ &:hover {
+ border-color: var(--ifm-color-primary);
+ color: var(--ifm-color-primary);
+ text-decoration: none;
+ }
+ }
+}
+
+.pageFeedback__loading {
+ padding: var(--strapi-spacing-3, 0.75rem) 0;
+ font-size: 0.9rem;
+ color: var(--ifm-font-color-secondary);
+}
+
+.pageFeedback__error {
+ padding: var(--strapi-spacing-2, 0.5rem) 0;
+ color: var(--ifm-color-danger);
+ font-size: 0.9rem;
+
+ p {
+ margin: 0 0 var(--strapi-spacing-2, 0.5rem);
+ }
+}
+
+.pageFeedback__retryButton {
+ padding: var(--strapi-spacing-1, 0.25rem) var(--strapi-spacing-3, 0.75rem);
+ border: 1px solid var(--ifm-color-danger);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--ifm-color-danger);
+ cursor: pointer;
+ font-size: 0.85rem;
+
+ &:hover {
+ background: var(--ifm-color-danger);
+ color: #fff;
+ }
+}
+
+/* Selection context shown above the form when triggered from L2 */
+.pageFeedback__selectionContext {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--strapi-spacing-2, 0.5rem);
+ padding: var(--strapi-spacing-2, 0.5rem) var(--strapi-spacing-3, 0.75rem);
+ margin-bottom: var(--strapi-spacing-2, 0.5rem);
+ border-left: 3px solid var(--ifm-color-primary);
+ background: var(--ifm-color-primary-lightest, rgba(99, 91, 255, 0.05));
+ border-radius: 0 6px 6px 0;
+ font-size: 0.85rem;
+ color: var(--ifm-font-color-secondary);
+ text-align: left;
+
+ i {
+ flex-shrink: 0;
+ margin-top: 2px;
+ color: var(--ifm-color-primary);
+ }
+}
+
+/* L2: Floating selection bubble */
+:global {
+ .selectionFeedback__bubble {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--strapi-spacing-1, 0.25rem);
+ padding: 6px 12px;
+ border: none;
+ border-radius: 20px;
+ background: var(--ifm-color-primary);
+ color: #fff;
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ white-space: nowrap;
+ @include transition;
+
+ &:hover {
+ background: var(--ifm-color-primary-dark);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+ }
+
+ i {
+ font-size: 1rem;
+ }
+ }
+
+ /* L2: Heading anchor feedback button */
+ .headingAnchor__button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--ifm-font-color-secondary);
+ cursor: pointer;
+ font-size: 0.9rem;
+ opacity: 0;
+ @include transition;
+
+ &:hover {
+ opacity: 1;
+ border-color: var(--ifm-color-primary);
+ color: var(--ifm-color-primary);
+ background: var(--ifm-color-primary-lightest, rgba(99, 91, 255, 0.05));
+ }
+ }
+
+}
diff --git a/docusaurus/src/components/ProductSwitcher/ProductSwitcher.jsx b/docusaurus/src/components/ProductSwitcher/ProductSwitcher.jsx
new file mode 100644
index 0000000000..7ce8ad9711
--- /dev/null
+++ b/docusaurus/src/components/ProductSwitcher/ProductSwitcher.jsx
@@ -0,0 +1,67 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { useLocation } from '@docusaurus/router';
+import styles from './ProductSwitcher.module.scss';
+
+const products = [
+ {
+ id: 'cms',
+ label: 'CMS Docs',
+ href: '/cms/intro',
+ icon: 'ph-fill ph-feather',
+ color: 'var(--strapi-primary-600)',
+ },
+ {
+ id: 'cloud',
+ label: 'Cloud Docs',
+ href: '/cloud/intro',
+ icon: 'ph-fill ph-cloud',
+ color: 'var(--strapi-secondary-500)',
+ },
+];
+
+export default function ProductSwitcher() {
+ const { pathname } = useLocation();
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ const current = pathname.startsWith('/cloud/') ? products[1] : products[0];
+ const other = current === products[0] ? products[1] : products[0];
+
+ // Close dropdown on outside click
+ useEffect(() => {
+ function handleClick(e) {
+ if (ref.current && !ref.current.contains(e.target)) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClick);
+ return () => document.removeEventListener('mousedown', handleClick);
+ }, []);
+
+ return (
+
+
+ {open && (
+
+ )}
+
+ );
+}
diff --git a/docusaurus/src/components/ProductSwitcher/ProductSwitcher.module.scss b/docusaurus/src/components/ProductSwitcher/ProductSwitcher.module.scss
new file mode 100644
index 0000000000..b09591782d
--- /dev/null
+++ b/docusaurus/src/components/ProductSwitcher/ProductSwitcher.module.scss
@@ -0,0 +1,102 @@
+@use '../../scss/mixins' as *;
+
+.productSwitcher {
+ position: relative;
+}
+
+.trigger {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 10px;
+ border: 1px solid var(--strapi-neutral-150);
+ border-radius: 6px;
+ background: transparent;
+ cursor: pointer;
+ font-family: var(--ifm-font-family-base);
+ font-size: var(--strapi-font-size-sm);
+ font-weight: 600;
+ color: var(--strapi-neutral-700);
+ transition: all 0.15s ease;
+ width: 100%;
+
+ &:hover {
+ border-color: var(--strapi-neutral-300);
+ background: var(--strapi-neutral-100);
+ }
+}
+
+.dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.label {
+ flex: 1;
+ text-align: left;
+}
+
+.caret {
+ font-size: 14px;
+ color: var(--strapi-neutral-400);
+}
+
+.dropdown {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ background: var(--strapi-neutral-0);
+ border: 1px solid var(--strapi-neutral-150);
+ border-radius: var(--strapi-radius-sm, 6px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+ z-index: 10;
+ overflow: hidden;
+}
+
+.option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 10px;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--strapi-neutral-600);
+ text-decoration: none;
+ transition: background 0.15s ease;
+
+ &:hover {
+ background: var(--strapi-neutral-100);
+ color: var(--strapi-neutral-800);
+ text-decoration: none;
+ }
+}
+
+@include dark {
+ .trigger {
+ border-color: var(--strapi-neutral-200);
+ color: var(--strapi-neutral-500);
+
+ &:hover {
+ border-color: var(--strapi-neutral-300);
+ background: var(--strapi-neutral-100);
+ }
+ }
+
+ .dropdown {
+ background: var(--strapi-neutral-100);
+ border-color: var(--strapi-neutral-200);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+ }
+
+ .option {
+ color: var(--strapi-neutral-500);
+
+ &:hover {
+ background: var(--strapi-neutral-200);
+ color: var(--strapi-neutral-700);
+ }
+ }
+}
diff --git a/docusaurus/src/components/ReadingProgressBar/ReadingProgressBar.jsx b/docusaurus/src/components/ReadingProgressBar/ReadingProgressBar.jsx
new file mode 100644
index 0000000000..648c96b5a0
--- /dev/null
+++ b/docusaurus/src/components/ReadingProgressBar/ReadingProgressBar.jsx
@@ -0,0 +1,53 @@
+import React, { useEffect, useRef, useCallback } from 'react';
+import { useLocation } from '@docusaurus/router';
+import styles from './ReadingProgressBar.module.scss';
+
+export default function ReadingProgressBar() {
+ const fillRef = useRef(null);
+ const rafRef = useRef(null);
+ const location = useLocation();
+
+ // Only show on doc pages (not homepage)
+ const isDocPage = location.pathname !== '/' && !location.pathname.startsWith('/home');
+
+ const updateProgress = useCallback(() => {
+ if (!fillRef.current) return;
+ const scrollTop = window.scrollY || document.documentElement.scrollTop;
+ const docHeight = document.documentElement.scrollHeight - window.innerHeight;
+ if (docHeight <= 0) {
+ fillRef.current.style.width = '0%';
+ return;
+ }
+ const pct = Math.min(100, Math.max(0, (scrollTop / docHeight) * 100));
+ fillRef.current.style.width = `${pct}%`;
+ }, []);
+
+ useEffect(() => {
+ if (!isDocPage) return;
+
+ function onScroll() {
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
+ rafRef.current = requestAnimationFrame(updateProgress);
+ }
+
+ window.addEventListener('scroll', onScroll, { passive: true });
+ // Also listen to resize (content may change height)
+ window.addEventListener('resize', onScroll, { passive: true });
+ // Initial calculation
+ updateProgress();
+
+ return () => {
+ window.removeEventListener('scroll', onScroll);
+ window.removeEventListener('resize', onScroll);
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
+ };
+ }, [isDocPage, location.pathname, updateProgress]);
+
+ if (!isDocPage) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/docusaurus/src/components/ReadingProgressBar/ReadingProgressBar.module.scss b/docusaurus/src/components/ReadingProgressBar/ReadingProgressBar.module.scss
new file mode 100644
index 0000000000..0288e8427f
--- /dev/null
+++ b/docusaurus/src/components/ReadingProgressBar/ReadingProgressBar.module.scss
@@ -0,0 +1,19 @@
+@use '../../scss/mixins' as *;
+
+.progressBar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ z-index: 201; /* Above navbar (z-index: 200) so backdrop-filter doesn't hide it */
+ background: transparent;
+ pointer-events: none;
+}
+
+.progressFill {
+ height: 100%;
+ background: linear-gradient(90deg, #4945FF 0%, #7B79FF 50%, #A855F7 100%);
+ will-change: width;
+ box-shadow: 0 0 8px rgba(73, 69, 255, 0.3);
+}
diff --git a/docusaurus/src/components/StepDetails/StepDetails.jsx b/docusaurus/src/components/StepDetails/StepDetails.jsx
new file mode 100644
index 0000000000..afd502799a
--- /dev/null
+++ b/docusaurus/src/components/StepDetails/StepDetails.jsx
@@ -0,0 +1,85 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import styles from './StepDetails.module.scss';
+
+const STORAGE_KEY = 'strapi-docs-completed-steps';
+
+function getCompletedSteps() {
+ if (typeof window === 'undefined') return {};
+ try {
+ return JSON.parse(sessionStorage.getItem(STORAGE_KEY) || '{}');
+ } catch {
+ return {};
+ }
+}
+
+function setStepCompleted(pageId, stepId) {
+ if (typeof window === 'undefined') return;
+ try {
+ const steps = getCompletedSteps();
+ if (!steps[pageId]) steps[pageId] = [];
+ if (!steps[pageId].includes(stepId)) {
+ steps[pageId].push(stepId);
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(steps));
+ }
+ } catch {}
+}
+
+function isStepCompleted(pageId, stepId) {
+ const steps = getCompletedSteps();
+ return steps[pageId]?.includes(stepId) || false;
+}
+
+function slugify(text) {
+ return text
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/(^-|-$)/g, '');
+}
+
+export default function StepDetails({ title, children, defaultOpen = false }) {
+ const stepId = slugify(title);
+ const [pageId, setPageId] = useState('');
+ const [completed, setCompleted] = useState(false);
+
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ const pid = window.location.pathname;
+ setPageId(pid);
+ setCompleted(isStepCompleted(pid, stepId));
+ }
+ }, [stepId]);
+
+ const handleToggle = useCallback((e) => {
+ const open = e.target.open;
+
+ // Mark as completed when opened (user has "read" it)
+ if (open && pageId && !completed) {
+ setStepCompleted(pageId, stepId);
+ setCompleted(true);
+ }
+ }, [pageId, stepId, completed]);
+
+ return (
+
+
+ {title}
+
+ {completed ? (
+
+ ) : (
+
+ )}
+
+
+ {children}
+
+ );
+}
diff --git a/docusaurus/src/components/StepDetails/StepDetails.module.scss b/docusaurus/src/components/StepDetails/StepDetails.module.scss
new file mode 100644
index 0000000000..1e728a4836
--- /dev/null
+++ b/docusaurus/src/components/StepDetails/StepDetails.module.scss
@@ -0,0 +1,74 @@
+/** StepDetails — Adds gamified progress tracking to standard details/accordion
+ * Inherits all styling from details.alert (details.scss)
+ * Only adds: checkmark icon + completed state border color
+ */
+@use '../../scss/mixins' as *;
+
+.stepDetails {
+ /* Let details.alert handle all base styling — we just layer on top */
+
+ summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 20px 28px !important;
+ }
+
+ > :not(summary) {
+ padding-left: 28px !important;
+ padding-right: 28px !important;
+ }
+
+ &[open] {
+ padding-bottom: 24px;
+ }
+}
+
+.checkmark {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ font-size: 20px;
+ flex-shrink: 0;
+ margin-left: 12px;
+ /* Position above the summary::after overlay so it's visible */
+ position: relative;
+ z-index: 2;
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+
+ i {
+ margin-right: 0 !important;
+ color: var(--strapi-neutral-300, #D4D4D8);
+ transition: color 0.3s;
+ }
+}
+
+.checkmarkVisible {
+ background: rgba(34, 197, 94, 0.1);
+
+ i {
+ color: #22C55E !important;
+ }
+}
+
+.completed {
+ border-color: rgba(34, 197, 94, 0.2) !important;
+
+ /* Override the left accent bar color to green */
+ &::after {
+ background: #22C55E !important;
+ }
+}
+
+@include dark {
+ .completed {
+ border-color: rgba(34, 197, 94, 0.15) !important;
+ }
+
+ .checkmarkVisible {
+ background: rgba(34, 197, 94, 0.15);
+ }
+}
diff --git a/docusaurus/src/components/ViewMode/AiPanel.js b/docusaurus/src/components/ViewMode/AiPanel.js
new file mode 100644
index 0000000000..681220bad1
--- /dev/null
+++ b/docusaurus/src/components/ViewMode/AiPanel.js
@@ -0,0 +1,302 @@
+import React, { useState, useEffect, useRef } from 'react';
+import BrowserOnly from '@docusaurus/BrowserOnly';
+import { useLocation } from '@docusaurus/router';
+import { KapaProvider, useChat } from '@kapaai/react-sdk';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import { useViewMode } from './ViewModeContext';
+import styles from './aiPanel.module.scss';
+
+const KAPA_INTEGRATION_ID = 'e35b7c7b-7ec8-4c1a-8a39-0ab7b6d8db3a';
+
+function CodeBlock({ children, ...props }) {
+ const [copied, setCopied] = useState(false);
+ const codeText = typeof children === 'string'
+ ? children
+ : React.Children.toArray(children).map(c =>
+ typeof c === 'string' ? c : c?.props?.children || ''
+ ).join('');
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(codeText);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
+ {children}
+
+ );
+}
+
+/**
+ * Finds page headings that are mentioned in an AI answer.
+ * Returns an array of { id, text } for headings referenced in the answer.
+ */
+function findMatchingHeadings(answerText) {
+ if (!answerText) return [];
+ const headings = document.querySelectorAll('article h2[id], article h3[id]');
+ const matches = [];
+ const answerLower = answerText.toLowerCase();
+
+ headings.forEach((h) => {
+ const text = h.textContent.trim();
+ // Match if the heading text (or a significant portion) appears in the answer
+ if (text.length > 3 && answerLower.includes(text.toLowerCase())) {
+ matches.push({ id: h.id, text });
+ }
+ });
+
+ return matches;
+}
+
+/**
+ * Extracts Tldr content from the current page DOM.
+ * The Tldr component renders as .
+ */
+function useTldrContent(viewMode) {
+ const [tldrText, setTldrText] = useState('');
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ if (viewMode !== 'ai') return;
+
+ const timer = setTimeout(() => {
+ const tldrEl = document.querySelector('.tldr');
+ if (tldrEl) {
+ const clone = tldrEl.cloneNode(true);
+ const strong = clone.querySelector('strong');
+ if (strong) strong.remove();
+ const icon = clone.querySelector('[class*="ph-"]');
+ if (icon) icon.remove();
+ setTldrText(clone.textContent.trim());
+ } else {
+ setTldrText('');
+ }
+ }, 100);
+
+ return () => clearTimeout(timer);
+ }, [pathname, viewMode]);
+
+ return tldrText;
+}
+
+function ChatInterface({ pageContext }) {
+ const [question, setQuestion] = useState('');
+ const messagesEndRef = useRef(null);
+ const { conversation, submitQuery, resetConversation, isGeneratingAnswer } = useChat();
+ const { pathname } = useLocation();
+
+ // Reset conversation on page navigation
+ useEffect(() => {
+ if (conversation.length > 0) {
+ resetConversation();
+ }
+ }, [pathname]);
+
+ // Auto-scroll to bottom
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [conversation]);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (question.trim() && !isGeneratingAnswer) {
+ // On the first message, prefix with page context so Kapa knows what page we're on
+ let query = question;
+ if (conversation.length === 0 && pageContext) {
+ query = `[Context: The user is reading the Strapi documentation page "${pageContext.title}" at ${pageContext.url}. Page summary: ${pageContext.summary}]\n\n${question}`;
+ }
+ submitQuery(query);
+ setQuestion('');
+ }
+ };
+
+ return (
+ <>
+
+ {conversation.length === 0 ? (
+
+ What would you like to know more about this topic?
+
+ ) : (
+ conversation.map((qa, index) => (
+
+
+ {/* Strip the context prefix from display */}
+ {qa.question.replace(/^\[Context:.*?\]\n\n/s, '')}
+
+
+ {qa.answer ? (
+ <>
+
+ {qa.answer}
+
+ {qa.sources && qa.sources.length > 0 && (
+
+ Sources:
+ {qa.sources.map((source, i) => (
+
+ {source.title}
+
+ ))}
+
+ )}
+ {(() => {
+ const headings = findMatchingHeadings(qa.answer);
+ if (headings.length === 0) return null;
+ return (
+
+ Jump to:
+ {headings.map((h) => (
+
+ ))}
+
+ );
+ })()}
+ >
+ ) : (
+
+ Thinking
+
+
+
+
+
+
+ )}
+
+
+ ))
+ )}
+
+
+
+ {conversation.length > 0 && (
+
+
+
+
+ )}
+
+
+ >
+ );
+}
+
+export default function AiPanel() {
+ const { viewMode, setViewMode } = useViewMode();
+ const isOpen = viewMode === 'ai';
+ const tldrText = useTldrContent(viewMode);
+
+ return (
+
+ );
+}
diff --git a/docusaurus/src/components/ViewMode/ViewModeContext.js b/docusaurus/src/components/ViewMode/ViewModeContext.js
new file mode 100644
index 0000000000..3b119d6e1a
--- /dev/null
+++ b/docusaurus/src/components/ViewMode/ViewModeContext.js
@@ -0,0 +1,91 @@
+import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
+
+const STORAGE_KEY = 'strapi-view-mode';
+const VIEW_MODES = ['elegant', 'markdown', 'ai'];
+
+function getInitialMode() {
+ if (typeof window === 'undefined') return 'elegant';
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored && stored !== 'ai' && VIEW_MODES.includes(stored)) return stored;
+ return 'elegant';
+ } catch {
+ return 'elegant';
+ }
+}
+
+const ViewModeContext = createContext({
+ viewMode: 'elegant',
+ setViewMode: () => {},
+});
+
+export function ViewModeProvider({ children }) {
+ const [viewMode, setViewModeState] = useState(getInitialMode);
+
+ const viewModeRef = useRef(viewMode);
+ viewModeRef.current = viewMode;
+
+ useEffect(() => {
+ document.documentElement.dataset.viewMode = viewMode;
+
+ function forceOpenDetails() {
+ document.querySelectorAll('article details:not([open])').forEach((el) => {
+ el.setAttribute('open', '');
+ el.dataset.forcedOpen = 'true';
+ });
+ }
+
+ if (viewMode === 'markdown') {
+ forceOpenDetails();
+
+ // Watch for details elements that appear after initial render (lazy hydration)
+ const observer = new MutationObserver(() => {
+ if (viewModeRef.current === 'markdown') {
+ forceOpenDetails();
+ }
+ });
+ const article = document.querySelector('article');
+ if (article) {
+ observer.observe(article, { childList: true, subtree: true });
+ }
+ return () => observer.disconnect();
+ } else {
+ document.querySelectorAll('article details[data-forced-open]').forEach((el) => {
+ el.removeAttribute('open');
+ delete el.dataset.forcedOpen;
+ });
+ }
+ }, [viewMode]);
+
+ const setViewMode = useCallback((mode) => {
+ if (!VIEW_MODES.includes(mode)) return;
+ setViewModeState(mode);
+ document.documentElement.dataset.viewMode = mode;
+
+ try {
+ if (mode === 'ai') {
+ // Don't touch storage for AI
+ } else {
+ localStorage.setItem(STORAGE_KEY, mode);
+ }
+ } catch {
+ // localStorage unavailable
+ }
+
+ window.dispatchEvent(
+ new CustomEvent('view-mode-change', { detail: { mode } })
+ );
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useViewMode() {
+ return useContext(ViewModeContext);
+}
+
+export { VIEW_MODES, STORAGE_KEY };
diff --git a/docusaurus/src/components/ViewMode/ViewModeSwitcher.js b/docusaurus/src/components/ViewMode/ViewModeSwitcher.js
new file mode 100644
index 0000000000..2625f61e27
--- /dev/null
+++ b/docusaurus/src/components/ViewMode/ViewModeSwitcher.js
@@ -0,0 +1,75 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { useViewMode } from './ViewModeContext';
+import styles from './viewModeSwitcher.module.scss';
+
+const SCROLL_THRESHOLD = 50;
+const MODES = [
+ { value: 'elegant', label: 'Elegant Mode', icon: 'sparkle' },
+ { value: 'markdown', label: 'Markdown Mode', icon: 'code' },
+ { value: 'ai', label: 'AI Mode', icon: 'robot' },
+];
+
+export default function ViewModeSwitcher() {
+ const { viewMode, setViewMode } = useViewMode();
+ const [visible, setVisible] = useState(true);
+ const lastScrollY = useRef(0);
+
+ // Auto-hide on scroll down, show on scroll up
+ useEffect(() => {
+ const handleScroll = () => {
+ const currentY = window.scrollY;
+ if (currentY < SCROLL_THRESHOLD) {
+ setVisible(true);
+ } else if (currentY > lastScrollY.current) {
+ setVisible(false);
+ } else {
+ setVisible(true);
+ }
+ lastScrollY.current = currentY;
+ };
+ window.addEventListener('scroll', handleScroll, { passive: true });
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ const handleKeyDown = useCallback((e) => {
+ const currentIndex = MODES.findIndex((m) => m.value === viewMode);
+ let nextIndex;
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
+ e.preventDefault();
+ nextIndex = (currentIndex + 1) % MODES.length;
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ nextIndex = (currentIndex - 1 + MODES.length) % MODES.length;
+ } else {
+ return;
+ }
+ setViewMode(MODES[nextIndex].value);
+ }, [viewMode, setViewMode]);
+
+ return (
+
+ {MODES.map((m) => {
+ const isActive = viewMode === m.value;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/docusaurus/src/components/ViewMode/aiPanel.module.scss b/docusaurus/src/components/ViewMode/aiPanel.module.scss
new file mode 100644
index 0000000000..9aebeba06a
--- /dev/null
+++ b/docusaurus/src/components/ViewMode/aiPanel.module.scss
@@ -0,0 +1,609 @@
+@use '../../scss/mixins' as *;
+
+.aiPanel {
+ position: fixed;
+ top: var(--ifm-navbar-height, 3.75rem);
+ right: 0;
+ bottom: 0;
+ width: 50vw;
+ transform: translateX(100%);
+ transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
+ background: var(--strapi-surface-0);
+ border-left: 1px solid var(--strapi-neutral-150);
+ z-index: 100;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+
+ &Open {
+ transform: translateX(0);
+ }
+
+ @include small-down {
+ width: 100vw;
+ top: 0;
+ // Must exceed Docusaurus navbar z-index (200) to fully cover on mobile
+ z-index: 300;
+ }
+
+ @include small-to-medium {
+ width: 40vw;
+ }
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--strapi-spacing-4) var(--strapi-spacing-5);
+ border-bottom: 1px solid var(--strapi-neutral-150);
+ flex-shrink: 0;
+
+ h3 {
+ margin: 0;
+ font-family: var(--strapi-font-family-technical);
+ font-size: 14px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--strapi-primary-600);
+ }
+}
+
+.backButton {
+ display: none;
+
+ @include small-down {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ border: none;
+ background: transparent;
+ color: var(--strapi-neutral-600);
+ cursor: pointer;
+ font-family: var(--strapi-font-family-technical);
+ font-size: 13px;
+ padding: 4px 8px;
+ border-radius: 6px;
+
+ &:hover {
+ background: var(--strapi-neutral-100);
+ }
+ }
+}
+
+.closeButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ cursor: pointer;
+ color: var(--strapi-neutral-600);
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background: var(--strapi-neutral-200);
+ }
+
+ i {
+ font-size: 16px;
+ line-height: 1;
+ }
+
+ @include small-down {
+ display: none;
+ }
+}
+
+.content {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--strapi-spacing-5);
+ display: flex;
+ flex-direction: column;
+}
+
+.summary {
+ margin-bottom: var(--strapi-spacing-6);
+
+ h4 {
+ font-family: var(--strapi-font-family-technical);
+ font-size: 12px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--strapi-neutral-400);
+ margin-bottom: var(--strapi-spacing-3);
+ }
+
+ p {
+ color: var(--strapi-neutral-700);
+ line-height: 1.7;
+ margin: 0;
+ }
+}
+
+.noSummary {
+ color: var(--strapi-neutral-400);
+ font-style: italic;
+ font-size: 14px;
+}
+
+.divider {
+ border: none;
+ border-top: 1px solid var(--strapi-neutral-150);
+ margin: var(--strapi-spacing-5) 0;
+}
+
+.chatPrompt {
+ font-family: var(--strapi-font-family-technical);
+ font-size: 14px;
+ color: var(--strapi-neutral-500);
+ margin-bottom: var(--strapi-spacing-4);
+}
+
+.chatTrigger {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 16px;
+ border: 1px solid var(--strapi-neutral-150);
+ border-radius: 8px;
+ background: var(--strapi-neutral-0);
+ color: var(--strapi-primary-600);
+ cursor: pointer;
+ font-family: var(--strapi-font-family-technical);
+ font-size: 13px;
+ font-weight: 600;
+ transition: border-color 0.2s ease, background-color 0.2s ease;
+
+ &:hover {
+ border-color: var(--strapi-primary-200);
+ background: var(--strapi-primary-100);
+ }
+
+ i {
+ font-size: 18px;
+ }
+}
+
+// Chat UI
+.chatMessages {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--strapi-spacing-4);
+ padding-bottom: var(--strapi-spacing-4);
+}
+
+.messageGroup {
+ display: flex;
+ flex-direction: column;
+ gap: var(--strapi-spacing-3);
+}
+
+.userMessage {
+ align-self: flex-end;
+ max-width: 85%;
+ background: transparent;
+ color: var(--strapi-primary-600);
+ padding: var(--strapi-spacing-2) 0;
+ font-family: var(--strapi-font-family-technical);
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.5;
+ word-wrap: break-word;
+ text-align: right;
+}
+
+
+.aiMessage {
+ align-self: flex-start;
+ max-width: 100%;
+ background: transparent;
+ border: none;
+ padding: var(--strapi-spacing-2) 0;
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.answerText {
+ white-space: normal;
+ word-wrap: break-word;
+
+ :global {
+ p {
+ margin: 6px 0;
+ font-size: 14px;
+ &:first-child { margin-top: 0; }
+ &:last-child { margin-bottom: 0; }
+ }
+
+ h1, h2, h3, h4, h5, h6 {
+ font-family: var(--strapi-font-family-technical);
+ font-weight: 700;
+ margin: 12px 0 4px;
+ }
+
+ h1 { font-size: 16px; }
+ h2 { font-size: 15px; }
+ h3 { font-size: 14px; }
+ h4, h5, h6 { font-size: 13px; }
+
+ a {
+ color: var(--strapi-primary-600);
+ text-decoration: none;
+ font-family: var(--strapi-font-family-technical);
+ font-size: 12px;
+ background: var(--strapi-neutral-100);
+ padding: 2px 5px;
+ border-radius: 3px;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ code {
+ font-family: var(--strapi-font-family-technical);
+ font-size: 12px;
+ background: var(--strapi-neutral-150);
+ padding: 2px 4px;
+ border-radius: 3px;
+ }
+
+ pre {
+ background: var(--strapi-neutral-100);
+ border: 1px solid var(--strapi-neutral-150);
+ border-radius: 4px;
+ padding: 10px;
+ overflow-x: auto;
+ margin: 0;
+ font-size: 12px;
+
+ code {
+ background: transparent;
+ padding: 0;
+ border-radius: 0;
+ font-size: 12px;
+ line-height: 1.5;
+ }
+ }
+
+ // Tables (rendered by remark-gfm)
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 8px 0;
+ font-size: 12px;
+ font-family: var(--strapi-font-family-technical);
+ }
+
+ th, td {
+ border: 1px solid var(--strapi-neutral-200);
+ padding: 6px 10px;
+ text-align: left;
+ }
+
+ th {
+ background: var(--strapi-neutral-100);
+ font-weight: 700;
+ }
+
+ ul, ol {
+ margin: 6px 0;
+ padding-left: 20px;
+ font-size: 14px;
+ }
+
+ li {
+ margin: 3px 0;
+ }
+
+ strong {
+ font-weight: 700;
+ }
+
+ hr {
+ border: none;
+ border-top: 1px solid var(--strapi-neutral-150);
+ margin: 8px 0;
+ }
+ }
+}
+
+.codeBlockWrapper {
+ position: relative;
+ margin: 8px 0;
+}
+
+.copyCodeButton {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 26px;
+ height: 26px;
+ border: none;
+ border-radius: 4px;
+ background: var(--strapi-neutral-200);
+ color: var(--strapi-neutral-600);
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.2s ease, background-color 0.2s ease;
+ z-index: 1;
+
+ i {
+ font-size: 13px;
+ }
+
+ &:hover {
+ background: var(--strapi-neutral-300);
+ }
+}
+
+.codeBlockWrapper:hover .copyCodeButton {
+ opacity: 1;
+}
+
+.sources {
+ margin-top: var(--strapi-spacing-3);
+ padding-top: var(--strapi-spacing-2);
+ border-top: 1px solid var(--strapi-neutral-150);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 12px;
+}
+
+.sourcesLabel {
+ font-family: var(--strapi-font-family-technical);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--strapi-neutral-400);
+ font-size: 11px;
+}
+
+.sourceLink {
+ color: var(--strapi-primary-600);
+ text-decoration: none;
+ font-size: 12px;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.thinking {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--strapi-neutral-400);
+ font-size: 13px;
+}
+
+.thinkingDots {
+ display: flex;
+ gap: 3px;
+}
+
+.dot {
+ width: 6px;
+ height: 6px;
+ background: var(--strapi-neutral-400);
+ border-radius: 50%;
+ animation: thinking 1.4s infinite ease-in-out;
+
+ &:nth-child(2) { animation-delay: 0.2s; }
+ &:nth-child(3) { animation-delay: 0.4s; }
+}
+
+@keyframes thinking {
+ 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
+ 40% { opacity: 1; transform: scale(1); }
+}
+
+// Chat input (fixed at bottom of panel)
+.chatInput {
+ display: flex;
+ gap: var(--strapi-spacing-2);
+ padding-top: var(--strapi-spacing-3);
+ border-top: 1px solid var(--strapi-neutral-150);
+ margin-top: auto;
+ flex-shrink: 0;
+}
+
+.textInput {
+ flex: 1;
+ padding: var(--strapi-spacing-3);
+ border: 1px solid var(--strapi-neutral-150);
+ border-radius: 8px;
+ background: var(--strapi-neutral-0);
+ color: var(--strapi-neutral-800);
+ font-size: 14px;
+ outline: none;
+ font-family: inherit;
+
+ &::placeholder {
+ color: var(--strapi-neutral-400);
+ }
+
+ &:focus {
+ border-color: var(--strapi-primary-600);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ }
+}
+
+.sendButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border: none;
+ border-radius: 8px;
+ background: var(--strapi-primary-600);
+ color: white;
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: background-color 0.2s ease;
+
+ &:hover:not(:disabled) {
+ background: var(--strapi-primary-700);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ i {
+ font-size: 16px;
+ }
+}
+
+.jumpLinks {
+ margin-top: var(--strapi-spacing-3);
+ padding-top: var(--strapi-spacing-2);
+ border-top: 1px solid var(--strapi-neutral-150);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.jumpButton {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 0;
+ border: none;
+ background: transparent;
+ color: var(--strapi-primary-600);
+ cursor: pointer;
+ font-family: var(--strapi-font-family-technical);
+ font-size: 12px;
+ text-align: left;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ i {
+ font-size: 12px;
+ flex-shrink: 0;
+ }
+}
+
+.chatActions {
+ display: flex;
+ gap: var(--strapi-spacing-2);
+ padding: var(--strapi-spacing-2) 0;
+ flex-shrink: 0;
+}
+
+.chatActionButton {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px;
+ border: 1px solid var(--strapi-neutral-150);
+ border-radius: 6px;
+ background: transparent;
+ color: var(--strapi-neutral-500);
+ cursor: pointer;
+ font-family: var(--strapi-font-family-technical);
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ transition: background-color 0.2s ease, color 0.2s ease;
+
+ &:hover {
+ background: var(--strapi-neutral-100);
+ color: var(--strapi-neutral-700);
+ }
+
+ i {
+ font-size: 13px;
+ }
+}
+
+@include dark {
+ .aiPanel {
+ border-left-color: var(--strapi-neutral-200);
+ }
+
+ .header {
+ border-bottom-color: var(--strapi-neutral-200);
+ }
+
+ .summary p {
+ color: var(--strapi-neutral-800);
+ }
+
+ .divider {
+ border-top-color: var(--strapi-neutral-200);
+ }
+
+ .chatTrigger {
+ border-color: var(--strapi-neutral-200);
+ background: var(--strapi-neutral-100);
+
+ &:hover {
+ border-color: var(--strapi-primary-400);
+ background: rgba(73, 69, 255, 0.15);
+ }
+ }
+
+ .aiMessage {
+ background: transparent;
+ }
+
+ .userMessage {
+ background: transparent;
+ color: var(--strapi-primary-400);
+ }
+
+ .textInput {
+ background: var(--strapi-neutral-100);
+ border-color: var(--strapi-neutral-200);
+ color: var(--strapi-neutral-800);
+ }
+
+ .sources {
+ border-top-color: var(--strapi-neutral-200);
+ }
+
+ .chatInput {
+ border-top-color: var(--strapi-neutral-200);
+ }
+
+ .chatActionButton {
+ border-color: var(--strapi-neutral-200);
+ color: var(--strapi-neutral-400);
+
+ &:hover {
+ background: var(--strapi-neutral-100);
+ color: var(--strapi-neutral-300);
+ }
+ }
+
+ .jumpLinks {
+ border-top-color: var(--strapi-neutral-200);
+ }
+
+ .jumpButton {
+ color: var(--strapi-primary-400);
+ }
+}
diff --git a/docusaurus/src/components/ViewMode/viewModeSwitcher.module.scss b/docusaurus/src/components/ViewMode/viewModeSwitcher.module.scss
new file mode 100644
index 0000000000..eaad3b2eaa
--- /dev/null
+++ b/docusaurus/src/components/ViewMode/viewModeSwitcher.module.scss
@@ -0,0 +1,104 @@
+@use '../../scss/mixins' as *;
+
+.switcher {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ position: sticky;
+ top: calc(var(--ifm-navbar-height, 3.75rem) + 8px);
+ z-index: 10;
+ margin-bottom: 8px;
+ padding: 6px 0;
+ transition: opacity 0.3s ease, transform 0.3s ease;
+ flex-wrap: wrap;
+ // Opaque background so it doesn't blend with content when scrolling
+ background: var(--strapi-surface-0);
+ // Ensure background covers full width and hides content scrolling underneath
+ margin-left: -16px;
+ margin-right: -16px;
+ padding-left: 16px;
+ padding-right: 16px;
+
+ @include small-down {
+ position: static;
+ background: transparent;
+ }
+}
+
+.hidden {
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(-10px);
+}
+
+.button {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--strapi-neutral-500);
+ cursor: pointer;
+ font-family: var(--strapi-font-family-technical);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ transition: background-color 0.2s ease, color 0.2s ease;
+ white-space: nowrap;
+
+ &:hover {
+ color: var(--strapi-neutral-700);
+ background: var(--strapi-neutral-100);
+ }
+
+ i {
+ font-size: 14px;
+ line-height: 1;
+ }
+}
+
+.active {
+ color: var(--strapi-primary-600);
+ background: var(--strapi-primary-100);
+
+ &:hover {
+ color: var(--strapi-primary-600);
+ background: var(--strapi-primary-100);
+ }
+}
+
+@include small-down {
+ .button {
+ font-size: 10px;
+ padding: 4px 7px;
+ letter-spacing: 0.03em;
+ }
+}
+
+@include dark {
+ .switcher {
+ background: var(--strapi-surface-0);
+ }
+
+ .button {
+ color: var(--strapi-neutral-400);
+
+ &:hover {
+ color: var(--strapi-neutral-300);
+ background: var(--strapi-neutral-100);
+ }
+ }
+
+ .active {
+ color: var(--strapi-primary-500);
+ background: rgba(73, 69, 255, 0.15);
+
+ &:hover {
+ color: var(--strapi-primary-500);
+ background: rgba(73, 69, 255, 0.15);
+ }
+ }
+}
diff --git a/docusaurus/src/components/WidthToggle/WidthToggle.js b/docusaurus/src/components/WidthToggle/WidthToggle.js
new file mode 100644
index 0000000000..e545647712
--- /dev/null
+++ b/docusaurus/src/components/WidthToggle/WidthToggle.js
@@ -0,0 +1,106 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import styles from './widthToggle.module.scss';
+
+const STORAGE_KEY = 'strapi-content-width';
+const SCROLL_THRESHOLD = 50;
+const WIDTHS = [
+ { value: 'default', label: 'Default width', icon: 'arrows-in-line-horizontal' },
+ { value: 'wide', label: 'Wide width', icon: 'arrows-out-line-horizontal' },
+ { value: 'max', label: 'Full width', icon: 'arrows-out' },
+];
+
+function getInitialWidth() {
+ if (typeof window === 'undefined') return 'default';
+ try {
+ return localStorage.getItem(STORAGE_KEY) || 'default';
+ } catch {
+ return 'default';
+ }
+}
+
+export default function WidthToggle() {
+ const [width, setWidth] = useState(getInitialWidth);
+ const [visible, setVisible] = useState(true);
+ const lastScrollY = useRef(0);
+
+ // Auto-hide on scroll down, show on scroll up
+ useEffect(() => {
+ const handleScroll = () => {
+ const currentY = window.scrollY;
+ if (currentY < SCROLL_THRESHOLD) {
+ setVisible(true);
+ } else if (currentY > lastScrollY.current) {
+ setVisible(false);
+ } else {
+ setVisible(true);
+ }
+ lastScrollY.current = currentY;
+ };
+ window.addEventListener('scroll', handleScroll, { passive: true });
+ return () => window.removeEventListener('scroll', handleScroll);
+ }, []);
+
+ // Sync DOM attribute on mount and changes
+ useEffect(() => {
+ if (width === 'default') {
+ delete document.documentElement.dataset.contentWidth;
+ } else {
+ document.documentElement.dataset.contentWidth = width;
+ }
+ }, [width]);
+
+ const handleChange = useCallback((value) => {
+ setWidth(value);
+ try {
+ if (value === 'default') {
+ localStorage.removeItem(STORAGE_KEY);
+ } else {
+ localStorage.setItem(STORAGE_KEY, value);
+ }
+ } catch {
+ // localStorage unavailable
+ }
+ }, []);
+
+ const handleKeyDown = useCallback((e) => {
+ const currentIndex = WIDTHS.findIndex((w) => w.value === width);
+ let nextIndex;
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
+ e.preventDefault();
+ nextIndex = (currentIndex + 1) % WIDTHS.length;
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ nextIndex = (currentIndex - 1 + WIDTHS.length) % WIDTHS.length;
+ } else {
+ return;
+ }
+ handleChange(WIDTHS[nextIndex].value);
+ }, [width, handleChange]);
+
+ return (
+
+ {WIDTHS.map((w) => {
+ const isActive = width === w.value;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/docusaurus/src/components/WidthToggle/widthToggle.module.scss b/docusaurus/src/components/WidthToggle/widthToggle.module.scss
new file mode 100644
index 0000000000..0cf8d7ddd0
--- /dev/null
+++ b/docusaurus/src/components/WidthToggle/widthToggle.module.scss
@@ -0,0 +1,58 @@
+@use '../../scss/mixins' as *;
+
+.widthToggle {
+ display: none;
+
+ @include medium-up {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ padding: 0;
+ border-radius: 6px;
+ background: transparent;
+ position: sticky;
+ top: calc(var(--ifm-navbar-height, 3.75rem) + 12px);
+ z-index: 1;
+ float: right;
+ margin-top: 4px;
+ transition: opacity 0.3s ease;
+ }
+}
+
+.hidden {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--ifm-color-emphasis-600);
+ cursor: pointer;
+ padding: 0;
+ transition: background-color 0.2s ease, color 0.2s ease;
+
+ &:hover:not(.active) {
+ background: var(--strapi-neutral-200);
+ }
+
+ i {
+ font-size: 16px;
+ line-height: 1;
+ }
+}
+
+.active {
+ background: var(--strapi-primary-600);
+ color: white;
+
+ &:hover {
+ background: var(--strapi-primary-600);
+ }
+}
diff --git a/docusaurus/src/components/index.js b/docusaurus/src/components/index.js
index 83d8ff22cb..af7b57eb28 100644
--- a/docusaurus/src/components/index.js
+++ b/docusaurus/src/components/index.js
@@ -8,4 +8,5 @@ export * from './LinkWithArrow/LinkWithArrow';
export * from './SearchBar/SearchBar';
export * from './SideBySide';
export * from './IdentityCard';
-export { default as HomepageAIButton } from './HomepageAIButton/HomepageAIButton';
\ No newline at end of file
+export { default as HomepageAIButton } from './HomepageAIButton/HomepageAIButton';
+export { default as NextSteps } from './NextSteps/NextSteps';
\ No newline at end of file
diff --git a/docusaurus/src/hooks/useScrollReveal.js b/docusaurus/src/hooks/useScrollReveal.js
new file mode 100644
index 0000000000..ee3433dc09
--- /dev/null
+++ b/docusaurus/src/hooks/useScrollReveal.js
@@ -0,0 +1,40 @@
+import { useEffect, useRef } from 'react';
+
+/**
+ * IntersectionObserver-based scroll reveal hook.
+ * Adds 'is-visible' class when element enters viewport.
+ * Fire-once by default.
+ */
+export function useScrollReveal({ threshold = 0.1, rootMargin = '0px 0px -40px 0px', once = true } = {}) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+
+ // Respect reduced motion preference
+ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (prefersReducedMotion) {
+ el.classList.add('is-visible');
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ entry.target.classList.add('is-visible');
+ if (once) observer.unobserve(entry.target);
+ }
+ },
+ { threshold, rootMargin }
+ );
+
+ observer.observe(el);
+
+ return () => observer.disconnect();
+ }, [threshold, rootMargin, once]);
+
+ return ref;
+}
+
+export default useScrollReveal;
diff --git a/docusaurus/src/pages/home/Home.jsx b/docusaurus/src/pages/home/Home.jsx
index ca0ab069f2..6cc66194b7 100644
--- a/docusaurus/src/pages/home/Home.jsx
+++ b/docusaurus/src/pages/home/Home.jsx
@@ -1,211 +1,374 @@
-import React, { useEffect, useState } from 'react';
-import clsx from 'clsx';
+import React, { useState, useCallback, useEffect, useRef } from 'react';
import Layout from '@theme/Layout';
import styles from './home.module.scss';
-import useIsBrowser from '@docusaurus/useIsBrowser';
-import {
- Button,
- Card,
- CardCta,
- CardIcon,
- CardDescription,
- CardImg,
- CardImgBg,
- CardTitle,
- Container,
- Hero,
- HeroDescription,
- HeroTitle,
- HomepageAIButton,
-} from '../../components';
-import NewsTicker from '@site/src/components/NewsTicker';
-import Icon from '../../components/Icon';
+import { HomepageAIButton } from '../../components';
+import ApiExplorer from '../../components/ApiExplorer/ApiExplorer';
import content from './_home.content';
-const NAVBAR_TRANSLUCENT_UNTIL_SCROLL_Y = 36;
+/**
+ * Copiable code block with copy button
+ */
+function CopyCodeBlock({ command, children }) {
+ const [copied, setCopied] = useState(false);
-export default function PageHome() {
- const [isNavbarTranslucent, setIsNavbarTranslucent] = useState(true);
- const isBrowser = useIsBrowser();
- const [isDarkTheme, setIsDarkTheme] = useState(false);
-
- const themeColors = isDarkTheme
- ? content.darkMode.colors
- : content.lightMode.colors;
+ const handleCopy = useCallback((e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ navigator.clipboard.writeText(command).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ }, [command]);
- useEffect(() => {
- if (isBrowser) {
- // Détecte le thème initial
- setIsDarkTheme(document.documentElement.getAttribute('data-theme') === 'dark');
-
- // Configure un observateur pour détecter les changements de thème
- const observer = new MutationObserver((mutations) => {
- mutations.forEach((mutation) => {
- if (mutation.attributeName === 'data-theme') {
- setIsDarkTheme(document.documentElement.getAttribute('data-theme') === 'dark');
- }
- });
- });
-
- observer.observe(document.documentElement, { attributes: true });
-
- return () => {
- observer.disconnect();
- };
- }
- }, [isBrowser]);
+ return (
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Animated counter that counts up from 0 to target when visible
+ */
+function AnimatedCounter({ target, suffix = '' }) {
+ const [count, setCount] = useState(0);
+ const ref = useRef(null);
+ const hasAnimated = useRef(false);
useEffect(() => {
- function scrollListener() {
- setIsNavbarTranslucent(window.scrollY <= NAVBAR_TRANSLUCENT_UNTIL_SCROLL_Y);
- }
+ const el = ref.current;
+ if (!el) return;
+
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting && !hasAnimated.current) {
+ hasAnimated.current = true;
+ const duration = 1600;
+ const steps = 60;
+ const increment = target / steps;
+ let current = 0;
+ const timer = setInterval(() => {
+ current += increment;
+ if (current >= target) {
+ setCount(target);
+ clearInterval(timer);
+ } else {
+ setCount(Math.floor(current));
+ }
+ }, duration / steps);
+ }
+ },
+ { threshold: 0.3 }
+ );
+
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [target]);
+
+ return (
+
+ {count}{suffix}
+
+ );
+}
- scrollListener();
- window.addEventListener('scroll', scrollListener);
+/**
+ * Scroll-reveal wrapper — fades in children when they enter the viewport
+ */
+function Reveal({ children, delay = 0, className = '' }) {
+ const ref = useRef(null);
+ const [visible, setVisible] = useState(false);
- return () => {
- window.removeEventListener('scroll', scrollListener);
- };
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const observer = new IntersectionObserver(
+ ([entry]) => { if (entry.isIntersecting) setVisible(true); },
+ { threshold: 0.15 }
+ );
+ observer.observe(el);
+ return () => observer.disconnect();
}, []);
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Explore link cards — categorized documentation navigation
+ */
+const EXPLORE_SECTIONS = [
+ {
+ label: 'Getting Started',
+ links: [
+ { icon: 'ph-compass', title: 'Quick Start Guide', desc: 'Discover Strapi in 5 minutes.', to: '/cms/quick-start' },
+ { icon: 'ph-download-simple', title: 'Installation', desc: 'Install Strapi with your preferred method.', to: '/cms/installation' },
+ { icon: 'ph-layout', title: 'Admin Panel', desc: 'Navigate and customize your dashboard.', to: '/cms/features/admin-panel' },
+ ],
+ },
+ {
+ label: 'Build Your Content',
+ links: [
+ { icon: 'ph-table', title: 'Content-Type Builder', desc: 'Define your data structure visually.', to: '/cms/features/content-type-builder' },
+ { icon: 'ph-pencil-simple-line', title: 'Content Manager', desc: 'Create, edit, and publish content.', to: '/cms/features/content-manager' },
+ { icon: 'ph-translate', title: 'Internationalization', desc: 'Manage content in multiple locales.', to: '/cms/features/internationalization' },
+ ],
+ },
+];
+
+/**
+ * Powerful Features — Growth/Enterprise features with bold card design
+ */
+const POWERFUL_FEATURES = [
+ {
+ icon: 'ph-eye',
+ title: 'Live Preview',
+ desc: 'See your content changes in real time on your front end before publishing.',
+ to: '/cms/features/preview',
+ },
+ {
+ icon: 'ph-clock-counter-clockwise',
+ title: 'Content History',
+ desc: 'Track every change to your content and restore any previous version in one click.',
+ to: '/cms/features/content-history',
+ },
+ {
+ icon: 'ph-clipboard-text',
+ title: 'Audit Logs',
+ desc: 'Monitor who did what and when. Full activity trail for your team.',
+ to: '/cms/features/audit-logs',
+ },
+];
+
+export default function PageHome() {
return (
- {isNavbarTranslucent && (
-
- )}
-