diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 122fa3e..7b78f20 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -55,6 +55,7 @@ Les toolboxes sont stockées dans `kv/toolboxes`. "one": ["mod1"], "two": [["mod1"], []] }, + "tutorial": true, "updatedAt": "2026-07-25T12:00:00.000Z" } ``` @@ -69,6 +70,7 @@ Notes : - Chaque identifiant d'outil présent dans `modules` doit apparaître exactement une fois dans `moduleOrder.one` et exactement une fois dans l'une des deux colonnes de `moduleOrder.two`. - Si une ancienne toolbox n'a pas encore `moduleOrder.two`, les outils sont répartis automatiquement depuis l'ordre une colonne avec une moitié en colonne gauche et une moitié en colonne droite. - Les données lourdes des outils ne sont pas stockées dans la toolbox, mais dans `modules`. +- `tutorial` est un indicateur temporaire réservé aux toolboxes créées depuis la prise en main. Il déclenche les premiers repères à l'ouverture, puis il est retiré automatiquement du stockage. ## Liens Jeu / Toolbox diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index bdf0db2..9584ced 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -596,6 +596,8 @@ export function validateSiteContent(site) { } }); + assertNonEmptyString(site, "home.onboarding.primaryAction"); + const onboardingCards = valueAt(site, "home.onboarding.cards"); assert.ok(Array.isArray(onboardingCards), "home.onboarding.cards must be an array"); assert.ok(onboardingCards.length >= 2, "home.onboarding.cards must contain at least 2 items"); @@ -629,6 +631,35 @@ export function validateSiteContent(site) { assertNonEmptyString({ slide }, "slide.text"); }); + const toolboxCreationTemplate = valueAt(site, "toolboxes.creationTemplate"); + assert.equal(typeof toolboxCreationTemplate, "object", "toolboxes.creationTemplate must be an object"); + assertNonEmptyString({ toolboxCreationTemplate }, "toolboxCreationTemplate.notepad.title"); + assertNonEmptyString({ toolboxCreationTemplate }, "toolboxCreationTemplate.notepad.text"); + assertNonEmptyString({ toolboxCreationTemplate }, "toolboxCreationTemplate.notepad.html"); + assertNonEmptyString({ toolboxCreationTemplate }, "toolboxCreationTemplate.links.moduleTitle"); + assertNonEmptyString({ toolboxCreationTemplate }, "toolboxCreationTemplate.links.title"); + + const toolboxTutorial = valueAt(site, "toolboxes.tutorial"); + assert.equal(typeof toolboxTutorial, "object", "toolboxes.tutorial must be an object"); + assertNonEmptyString({ toolboxTutorial }, "toolboxTutorial.eyebrow"); + assertNonEmptyString({ toolboxTutorial }, "toolboxTutorial.title"); + assertNonEmptyString({ toolboxTutorial }, "toolboxTutorial.text"); + assertNonEmptyString({ toolboxTutorial }, "toolboxTutorial.dismissLabel"); + assertNonEmptyString({ toolboxTutorial }, "toolboxTutorial.nextLabel"); + assertNonEmptyString({ toolboxTutorial }, "toolboxTutorial.doneLabel"); + assert.ok(Array.isArray(toolboxTutorial.items), "toolboxes.tutorial.items must be an array"); + assert.ok(toolboxTutorial.items.length >= 4, "toolboxes.tutorial.items must contain at least 4 items"); + toolboxTutorial.items.forEach((item, index) => { + assertNonEmptyString({ item }, "item.target"); + assertNonEmptyString({ item }, "item.icon"); + assertNonEmptyString({ item }, "item.title"); + assertNonEmptyString({ item }, "item.text"); + if (item.placement) { + assert.ok(["above", "below", "right"].includes(item.placement), "item.placement must be above, below or right"); + } + assert.ok(index < 6, "toolboxes.tutorial.items should stay compact"); + }); + const lines = valueAt(site, "home.origin.lines"); assert.ok(Array.isArray(lines), "home.origin.lines must be an array"); assert.ok(lines.length >= 2, "home.origin.lines must contain at least 2 items"); diff --git a/tests/static-app.test.mjs b/tests/static-app.test.mjs index 9796ff5..a3f6004 100644 --- a/tests/static-app.test.mjs +++ b/tests/static-app.test.mjs @@ -64,9 +64,12 @@ test("vite entrypoint and app shell are wired", async () => { assert.match(homePage, /export function HomePage/); assert.match(homePage, /HomeOnboardingSection/); assert.match(homePage, /import\.meta\.env\.DEV \|\| toolboxes\.length === 0/); + assert.match(homePage, /setCreateModal\(\{ tutorial: true \}\)/); assert.match(homePage, /dragon-home\.png/); assert.match(homeOnboarding, /export function HomeOnboardingSection/); assert.match(homeOnboarding, /prefers-reduced-motion: reduce/); + assert.match(homeOnboarding, /onCreateToolbox/); + assert.match(homeOnboarding, /content\.primaryAction/); assert.match(homeOnboarding, /content\.sliderTitle/); assert.match(homeOnboarding, /home-onboarding-dots/); assert.match(aboutPage, /export function AboutPage/); diff --git a/tests/static-toolboxes.test.mjs b/tests/static-toolboxes.test.mjs index 543207e..9a8d414 100644 --- a/tests/static-toolboxes.test.mjs +++ b/tests/static-toolboxes.test.mjs @@ -21,6 +21,7 @@ test("toolbox storage, cards and pages are wired", async () => { const indexedToolboxesHook = await readFile("website/src/features/toolboxes/storage/useIndexedToolboxes.js", "utf8"); const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8"); const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8"); + const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8"); const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8"); const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8"); const inlineEditHook = await readFile("website/src/hooks/useInlineEdit.js", "utf8"); @@ -34,6 +35,10 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(toolboxActions, /export function useToolboxActions/); assert.match(toolboxActions, /importAllToolboxesPayload/); assert.match(toolboxActions, /async function addImageFiles/); + assert.match(toolboxActions, /creationTemplate/); + assert.match(toolboxActions, /type: "notepad"/); + assert.match(toolboxActions, /type: "links"/); + assert.match(toolboxActions, /#\/library/); assert.match(`${source}\n${toolboxActions}`, /features\/toolboxes\/storage\/toolboxStorage\.js/); assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/); assert.match(toolboxStorage, /export function normalizeToolboxIcon/); @@ -85,6 +90,14 @@ test("toolbox storage, cards and pages are wired", async () => { assert.match(toolboxPages, /moduleColumns/); assert.match(toolboxPages, /normalizeCombosData/); assert.match(toolboxPages, /function ToolboxView/); + assert.match(toolboxPages, /GuidedTooltipTour/); + assert.match(toolboxPages, /data-toolbox-tutorial-target/); + assert.match(guidedTooltipTour, /document\.querySelector/); + assert.match(guidedTooltipTour, /--tutorial-arrow-left/); + assert.match(guidedTooltipTour, /--tutorial-arrow-top/); + assert.match(guidedTooltipTour, /data-placement/); + assert.match(toolboxPages, /toolbox\.tutorial === true/); + assert.match(toolboxPages, /tutorial: false/); assert.doesNotMatch(toolboxPages, /usePointerReorder/); assert.match(storageQuota, /export function StorageQuota/); assert.match(storageQuota, /role="progressbar"/); diff --git a/website/public/data/site.json b/website/public/data/site.json index f3bbbd3..4cccfe7 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -1186,6 +1186,7 @@ "eyebrow": "Prise en main", "title": "Votre première toolbox", "description": "Une toolbox est votre espace personnel pour garder vos notes, listes, images, liens et outils utiles dans ce navigateur.", + "primaryAction": "Créer ma première toolbox", "stepsTitle": "Les étapes", "sliderTitle": "Aperçu", "sliderLabel": "Aperçu de la prise en main", @@ -1430,6 +1431,64 @@ "toastPositionLeft": "Afficher les alertes en bas à gauche", "toastPositionRight": "Afficher les alertes en bas à droite", "summary": "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs outils restent accessibles rapidement, en local, par jeu ou par usage.", + "creationTemplate": { + "notepad": { + "title": "Bienvenue", + "text": "Profite bien de ta nouvelle toolbox.", + "html": "
Profite bien de ta nouvelle toolbox.
" + }, + "links": { + "moduleTitle": "Liens utiles", + "title": "Librairie des outils" + } + }, + "tutorial": { + "eyebrow": "Premiers repères", + "title": "Votre toolbox est prête", + "text": "Avant d’ajouter du contenu, voici les actions principales à connaître.", + "dismissLabel": "Masquer les repères", + "nextLabel": "Suivant", + "doneLabel": "Terminer", + "items": [ + { + "target": "icon", + "icon": "picture", + "title": "Changer l’icône", + "text": "Cliquez sur l’icône de la toolbox pour choisir une image plus reconnaissable." + }, + { + "target": "title", + "icon": "edit", + "title": "Renommer la toolbox", + "text": "Cliquez sur son nom pour le modifier directement." + }, + { + "target": "addTool", + "icon": "add", + "title": "Ajouter un outil", + "text": "Utilisez le bouton Ajouter un outil pour choisir ce dont vous avez besoin." + }, + { + "target": "moduleTitle", + "icon": "edit", + "title": "Renommer un outil", + "text": "Cliquez sur le titre d’un outil, comme Bloc notes, pour l’adapter à votre usage." + }, + { + "target": "deleteTool", + "icon": "trash", + "title": "Retirer un outil", + "text": "Chaque outil ajouté peut être retiré de la toolbox avec sa corbeille." + }, + { + "target": "exportAll", + "placement": "right", + "icon": "export", + "title": "Sauvegarder régulièrement", + "text": "Pensez à exporter vos toolboxes avant un nettoyage du navigateur, un changement de machine ou une réinstallation." + } + ] + }, "storageHelp": { "title": "Sauvegardes locales", "text": "Vos toolboxes restent dans l'IndexedDB de ce navigateur. Rien n’est envoyé sur un serveur.", diff --git a/website/src/components/AppOverlays.jsx b/website/src/components/AppOverlays.jsx index d8d623b..4f804e7 100644 --- a/website/src/components/AppOverlays.jsx +++ b/website/src/components/AppOverlays.jsx @@ -5,6 +5,12 @@ import { ConfirmModal, CreateToolboxModal, LinkToolboxModal, NotificationToast } import { navigate } from "../router/hashRouter.js"; import { uid } from "../features/toolboxes/storage/toolboxStorage.js"; +function scrollToTopAfterNavigation() { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => window.scrollTo({ top: 0, left: 0, behavior: "auto" })); + }); +} + export function InlineNotice({ className = "", iconName = "link", message, tone = "warning" }) { if (!message) return null; const classes = ["inline-notice", `is-${tone}`, className].filter(Boolean).join(" "); @@ -34,7 +40,8 @@ export function AppOverlays({ toastPosition, getGame, toolboxes, - links + links, + setPendingTutorialToolboxId }) { return ( <> @@ -47,9 +54,13 @@ export function AppOverlays({ const gameId = createModal.gameId || ""; setCreateModal(null); if (!name) return; - const toolbox = actions.createToolbox(name, gameId); + const toolbox = actions.createToolbox(name, gameId, { tutorial: createModal.tutorial === true }); + if (toolbox && createModal.tutorial === true) setPendingTutorialToolboxId?.(toolbox.id); if (toolbox && gameId) setDrawerGameId(gameId); - else if (toolbox) navigate(`/toolbox/${toolbox.id}`); + else if (toolbox) { + navigate(`/toolbox/${toolbox.id}`); + scrollToTopAfterNavigation(); + } }} />} {linkModalGameId &&{currentStep.text}
+ +{content.sidebar.note}
Toolbox
${welcomeText}
`; + const libraryLink = { + id: uid("link"), + title: creationTemplate?.links?.title || "Librairie des outils", + url: getLibraryUrl() + }; const toolbox = { id: uid("tbx"), name: name.trim() || "Nouvelle toolbox", updatedAt: now, modules: [ - { id: uid("mod"), type: "notepad", title: "Notes rapides" }, - { id: uid("mod"), type: "checklist" } + { id: notepadModuleId, type: "notepad", title: creationTemplate?.notepad?.title || "Bienvenue" }, + { id: linksModuleId, type: "links", title: creationTemplate?.links?.moduleTitle || "Liens utiles" } ] }; + if (options.tutorial === true) toolbox.tutorial = true; if (!store.setToolboxes([toolbox, ...store.toolboxes])) return null; + store.updateModuleData(toolbox.id, notepadModuleId, { html: welcomeHtml, text: welcomeText, updatedAt: now }, "notepad"); + store.updateModuleData(toolbox.id, linksModuleId, { links: [libraryLink] }, "links"); if (gameId) store.setLinks({ ...store.links, [gameId]: toolbox.id }); return toolbox; } diff --git a/website/src/main.jsx b/website/src/main.jsx index 60852a6..f2b46f3 100644 --- a/website/src/main.jsx +++ b/website/src/main.jsx @@ -25,6 +25,7 @@ function App() { const [linkModalGameId, setLinkModalGameId] = useState(""); const [drawerGameId, setDrawerGameId] = useState(""); const [image, setImage] = useState(null); + const [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState(""); const [notifications, setNotifications] = useState([]); const [storageError, setStorageError] = useState(""); const handleStorageError = useCallback((message) => setStorageError(message), []); @@ -61,6 +62,7 @@ function App() { const actions = useToolboxActions({ store, notify, + creationTemplate: siteContent?.toolboxes?.creationTemplate, setCreateModal, setConfirmModal, setLinkModalGameId, @@ -114,6 +116,8 @@ function App() { updateToolbox={actions.updateToolbox} updateModuleData={store.updateModuleData} addImageFiles={actions.addImageFiles} + pendingTutorialToolboxId={pendingTutorialToolboxId} + setPendingTutorialToolboxId={setPendingTutorialToolboxId} /> {drawerGameId && ({content.origin.eyebrow}
diff --git a/website/src/styles/_home.scss b/website/src/styles/_home.scss index d741947..0189b44 100644 --- a/website/src/styles/_home.scss +++ b/website/src/styles/_home.scss @@ -231,6 +231,18 @@ span { font-size: var(--font-size-lg); } +.home-onboarding-create-button { + min-height: 40px; + padding-inline: var(--space-4); +} + +.home-onboarding-steps-action { + display: flex; + justify-content: flex-start; + padding-top: var(--space-2); + padding-left: calc(28px + var(--space-3)); +} + .home-onboarding-preview { display: grid; align-content: start; diff --git a/website/src/styles/_icons.scss b/website/src/styles/_icons.scss index 8ba4455..607f048 100644 --- a/website/src/styles/_icons.scss +++ b/website/src/styles/_icons.scss @@ -62,6 +62,16 @@ -webkit-mask-image: url("/static/icons/link.svg"); } +.ui-icon-picture { + mask-image: url("/static/icons/picture.svg"); + -webkit-mask-image: url("/static/icons/picture.svg"); +} + +.ui-icon-toolbox { + mask-image: url("/static/icons/toolbox.svg"); + -webkit-mask-image: url("/static/icons/toolbox.svg"); +} + .ui-icon-settings { mask-image: url("/static/icons/settings.svg"); -webkit-mask-image: url("/static/icons/settings.svg"); diff --git a/website/src/styles/_responsive.scss b/website/src/styles/_responsive.scss index 846b3c7..669dd31 100644 --- a/website/src/styles/_responsive.scss +++ b/website/src/styles/_responsive.scss @@ -83,6 +83,14 @@ width: 100%; } + .home-onboarding-steps-action { + padding-left: 0; + } + + .home-onboarding-create-button { + width: 100%; + } + .hero, .game-hero, .page-hero, diff --git a/website/src/styles/toolboxes/_layout.scss b/website/src/styles/toolboxes/_layout.scss index 47b02c9..d192ca1 100644 --- a/website/src/styles/toolboxes/_layout.scss +++ b/website/src/styles/toolboxes/_layout.scss @@ -178,6 +178,125 @@ outline-offset: 3px; } +.toolbox-tutorial-tooltip { + position: fixed; + z-index: 640; + display: grid; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid rgba(246, 196, 83, 0.34); + border-radius: var(--radius-md); + background: + radial-gradient(circle at 12% 0%, rgba(246, 196, 83, 0.12), transparent 36%), + rgba(7, 10, 24, 0.98); + box-shadow: + var(--shadow-lg), + 0 0 24px rgba(139, 92, 246, 0.18); +} + +.toolbox-tutorial-tooltip::before { + content: ""; + position: absolute; + top: -6px; + left: var(--tutorial-arrow-left, 50%); + width: 10px; + height: 10px; + border-top: 1px solid rgba(246, 196, 83, 0.34); + border-left: 1px solid rgba(246, 196, 83, 0.34); + background: rgba(7, 10, 24, 0.98); + transform: translateX(-50%) rotate(45deg); +} + +.toolbox-tutorial-tooltip[data-placement="above"]::before { + top: auto; + bottom: -6px; + border-top: 0; + border-left: 0; + border-right: 1px solid rgba(246, 196, 83, 0.34); + border-bottom: 1px solid rgba(246, 196, 83, 0.34); +} + +.toolbox-tutorial-tooltip[data-placement="right"]::before { + top: var(--tutorial-arrow-top, 50%); + left: -6px; + border-top: 0; + border-left: 1px solid rgba(246, 196, 83, 0.34); + border-right: 0; + border-bottom: 1px solid rgba(246, 196, 83, 0.34); + transform: translateY(-50%) rotate(45deg); +} + +.toolbox-tutorial-heading { + display: flex; + align-items: center; + gap: var(--space-2); + padding-right: var(--space-6); +} + +.toolbox-tutorial-heading .ui-icon { + width: 20px; + height: 20px; + color: var(--color-accent-gold); +} + +.toolbox-tutorial-heading strong { + color: var(--color-text-primary); + line-height: 1.25; +} + +.toolbox-tutorial-tooltip p { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + line-height: 1.5; +} + +.toolbox-tutorial-count { + width: fit-content; + padding: 2px 7px; + border: 1px solid rgba(246, 196, 83, 0.22); + border-radius: var(--radius-pill); + color: var(--color-accent-gold); + font-size: var(--font-size-xs); + font-weight: 900; +} + +.toolbox-tutorial-close { + position: absolute; + top: var(--space-3); + right: var(--space-3); + width: 30px; + min-width: 30px; + min-height: 30px; + justify-content: center; + padding: 0; + border-color: transparent; + background: transparent; + color: var(--color-text-muted); +} + +.toolbox-tutorial-close:hover { + border-color: rgba(150, 165, 205, 0.18); + background: rgba(21, 26, 48, 0.62); + color: var(--color-text-primary); + box-shadow: none; +} + +.toolbox-tutorial-close .ui-icon { + width: 16px; + height: 16px; +} + +.toolbox-tutorial-tooltip footer { + display: flex; + justify-content: flex-end; +} + +.toolbox-tutorial-tooltip footer button { + min-height: 36px; + padding-inline: var(--space-3); +} + .tool-add-controls { position: relative; z-index: 280;