diff --git a/.changeset/quick-start-tour.md b/.changeset/quick-start-tour.md new file mode 100644 index 00000000..6f9275e2 --- /dev/null +++ b/.changeset/quick-start-tour.md @@ -0,0 +1,5 @@ +--- +"@matdata/yasgui": minor +--- + +"How MatGUI works": a quick start tour of five pages, each with three things to know and a small illustration, opens the first time someone uses MatGUI and stays available from the help menu and the Ctrl+K search. It links to the full user guide. Embedders can leave it out with `showQuickStart: false`, or keep it in the help menu only with `quickStartOnFirstVisit: false`. diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 9d5435fc..962b7dba 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -701,6 +701,13 @@ interface Config { // Search for saved queries, commands and settings in the top bar, also opened with Ctrl+K / Cmd+K showCommandPalette?: boolean; // default: true + // "How MatGUI works" quick start tour in the help menu of the top bar + showQuickStart?: boolean; // default: true + + // Open the quick start tour on the first visit in a browser (remembered in local storage under + // "matgui_quickStart_seen"). Set to false to only offer it in the help menu + quickStartOnFirstVisit?: boolean; // default: true + // Layout orientation: 'vertical' or 'horizontal' orientation?: 'vertical' | 'horizontal'; // default: 'vertical' diff --git a/docs/user-guide.md b/docs/user-guide.md index f62d5374..890bb24f 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -14,6 +14,7 @@ - [Common SPARQL Query Types](#common-sparql-query-types) - [Getting Started](#getting-started) - [Accessing MatGUI](#accessing-matgui) + - [Quick Start Tour](#quick-start-tour) - [Running Matgui with Docker](#running-matgui-with-docker) - [Your First Query](#your-first-query) - [Saving Your Work](#saving-your-work) @@ -150,6 +151,20 @@ This excellent resource covers: Matgui is available at: **[https://matgui.matdata.eu/](https://matgui.matdata.eu/)** +### Quick Start Tour + +The first time you open MatGUI in a browser, **How MatGUI works** shows you around in five short pages, with three things to know on each: + +1. **Write and run a query**: pick an endpoint, write with autocomplete, run with `Ctrl+Enter` +2. **Explore the results**: switch views, describe a resource, download the results +3. **Keep your queries**: work in tabs, save to a workspace, share a link +4. **Let MatGUI help**: insert snippets, look at the endpoint, ask the AI assistant (or hover an IRI, when the site has no AI assistant) +5. **Find your way**: search everything with `Ctrl+K`, open the side panel with `Ctrl+B`, switch the theme and open the settings + +Click **Next** and **Back**, the dots at the bottom, or use the `←` and `→` keys to move between the pages. **Read the full user guide** opens this guide. Close the tour with **Skip**, **Start querying** on the last page, the × or `Esc`. MatGUI remembers in your browser that you saw it and doesn't open it again by itself. + +To see it again, click **Help** (?) in the top bar and pick **How MatGUI works**, or search for it with `Ctrl+K`. Sites that embed MatGUI can leave the tour out, or keep it in the help menu without opening it on the first visit. + ### Running Matgui with Docker You can run Matgui locally using Docker, which is especially useful when working with local SPARQL endpoints. @@ -353,7 +368,7 @@ From left to right: - **MatGUI**: the name of the application (sites that embed MatGUI can hide it) - **Query tabs**: one tab per query. The active tab is raised. Tabs that hold a saved (managed) query show a small dot before their name; the dot turns amber when the query has unsaved changes. Hover a tab to show its close button (×), click **+** to add a tab - **Search queries and commands** (`Ctrl+K`, or `⌘K` on a Mac): see [Search](#search) -- **Help** (?): opens the help menu with **What's new** and the **Documentation** (see [Help Menu and What's New](#help-menu-and-whats-new)). A blue dot on the button means there is a release you haven't seen yet +- **Help** (?): opens the help menu with **How MatGUI works** (the [quick start tour](#quick-start-tour)), **What's new** and the **Documentation** (see [Help Menu and What's New](#help-menu-and-whats-new)). A blue dot on the button means there is a release you haven't seen yet - **Theme** (sun / moon): switches between the light and dark theme - **Settings** (gear): opens the settings of the active tab @@ -363,7 +378,7 @@ Click **Search queries and commands** in the top bar, or press `Ctrl+K` (`⌘K` - **Queries**: the saved queries of all your workspaces, with the workspace and folder they are in. Pick one to open it in a new tab, or to go to the tab that already has it open - **Snippets**: the code snippets of the [Insert menu](#insert-menu), found by name, group or the code they write (`filter` finds the comparison snippets). Pick one to insert it at the cursor -- **Commands**: Run query, New tab, Insert snippet, Insert PREFIX declarations, Switch endpoint, Explore endpoint, Show saved queries, Toggle side panel, Toggle side-by-side layout, Format, Share, Save, Download, full screen editor, dark mode, What's new and the documentation. Commands with a shortcut show it at the right +- **Commands**: Run query, New tab, Insert snippet, Insert PREFIX declarations, Switch endpoint, Explore endpoint, Show saved queries, Toggle side panel, Toggle side-by-side layout, Format, Share, Save, Download, full screen editor, dark mode, How MatGUI works, What's new and the documentation. Commands with a shortcut show it at the right - **Settings**: every page of the settings (General, SPARQL endpoints, Workspaces, Prefixes, Editor, AI, Import / export, Keyboard shortcuts, About), for example type `prefixes` and pick **Settings: Prefixes** Type `>` first to search commands and settings only. Snippets and settings show up once you type something. Use `↑` and `↓` to move, `Enter` to open and `Esc` to close. @@ -372,12 +387,13 @@ Type `>` first to search commands and settings only. Snippets and settings show Click **Help** (?) in the top bar to open the help menu: +- **How MatGUI works**: the [quick start tour](#quick-start-tour), five short pages that show you around - **What's new**: what changed in each version of MatGUI since 5.0.0, newest first. Every change is marked **New**, **Improved** or **Fixed**; use the buttons at the top to show only one kind. Releases you haven't seen yet are marked **New to you**. **Full release notes on GitHub** opens the complete notes, including the changes for developers - **Documentation**: opens this user guide in a new browser tab After MatGUI is updated, a blue dot on the Help button and next to **What's new** tells you there is something new. The dot goes away once you open What's new. On your first visit there is no dot. You can also open it from the [search](#search) (`Ctrl+K`, then type `what's new`). Press `Esc` to close it. -Sites that embed MatGUI can leave out What's new (`showWhatsNew: false`) or the documentation link (`helpUrl: false`). +Sites that embed MatGUI can leave out the quick start tour (`showQuickStart: false`), What's new (`showWhatsNew: false`) or the documentation link (`helpUrl: false`). ### Query Card diff --git a/packages/yasgui/src/CommandPalette.ts b/packages/yasgui/src/CommandPalette.ts index 86661586..e1573115 100644 --- a/packages/yasgui/src/CommandPalette.ts +++ b/packages/yasgui/src/CommandPalette.ts @@ -386,6 +386,18 @@ export default class CommandPalette { }, }); } + if (yasgui.quickStart) { + const quickStart = yasgui.quickStart; + commands.push({ + section: "commands", + label: "How MatGUI works", + icon: "compass", + keywords: "help quick start tour introduction onboarding", + run: () => { + quickStart.open(); + }, + }); + } if (yasgui.whatsNew) { commands.push({ section: "commands", diff --git a/packages/yasgui/src/QuickStart.scss b/packages/yasgui/src/QuickStart.scss new file mode 100644 index 00000000..807efa42 --- /dev/null +++ b/packages/yasgui/src/QuickStart.scss @@ -0,0 +1,423 @@ +// `.yasgui > div` lays out the tab panels as a column; the quick start is not one of them +.yasgui > div.yasgui-quickStart:not(.tabsList) { + flex-direction: row; + overflow: auto; + min-height: 0; + + &[hidden] { + display: none; + } +} + +// "How MatGUI works": the quick start tour, opened on the first visit and from the help menu +.yasgui-quickStart { + // Colours of the illustrations + --qs-keyword: #a3367f; + --qs-iri: #0563bb; + --qs-variable: #2f7d4f; + --qs-folder: #f2b300; + + position: fixed; + inset: 0; + z-index: 10060; + display: flex; + justify-content: center; + align-items: center; + padding: 24px 16px; + box-sizing: border-box; + background: var(--yasgui-scrim, rgba(28, 38, 51, 0.28)); + font-family: var(--yasgui-font-family, inherit); + font-size: 13px; + color: var(--yasgui-ink, #1c2633); + + &[hidden] { + display: none; + } + + [data-theme="dark"] & { + --qs-keyword: #e48fc9; + --qs-iri: #5aa8f2; + --qs-variable: #6fcf97; + } + + &__dialog { + position: relative; + width: 760px; + max-width: 100%; + max-height: 100%; + display: flex; + flex-direction: column; + overflow: auto; + border-radius: var(--yasgui-radius-lg, 16px); + background: var(--yasgui-surface-raised, #ffffff); + box-shadow: var(--yasgui-shadow-overlay, 0 16px 48px rgba(28, 38, 51, 0.22), 0 0 0 1px rgba(28, 38, 51, 0.06)); + outline: none; + } + + &__header { + position: relative; + padding: 24px 56px 4px 28px; + } + + &__eyebrow { + font-size: 12px; + font-weight: 500; + color: var(--yasgui-brand, #0563bb); + } + + &__title { + margin: 6px 0 0; + font-family: inherit; + font-size: 20px; + font-weight: 600; + line-height: 1.3; + letter-spacing: -0.01em; + color: var(--yasgui-ink, #1c2633); + } + + &__lead { + margin: 6px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--yasgui-ink-muted, #56606c); + } + + &__close { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: var(--yasgui-radius-button, 8px); + background: transparent; + color: var(--yasgui-ink-muted, #56606c); + cursor: pointer; + + &:hover { + background: var(--yasgui-surface-sunken, #edf1f6); + color: var(--yasgui-ink, #1c2633); + } + } + + &__items { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin: 0; + padding: 20px 28px 24px; + list-style: none; + } + + &__item { + display: flex; + flex-direction: column; + gap: 12px; + margin: 0; + + h3 { + margin: 0; + font-family: inherit; + font-size: 14px; + font-weight: 600; + line-height: 1.35; + color: var(--yasgui-ink, #1c2633); + } + + p { + margin: 4px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--yasgui-ink-body, #3a4552); + } + } + + &__illustration { + height: 112px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--yasgui-radius-md, 10px); + background: var(--yasgui-surface, #f1f4f8); + box-shadow: inset 0 0 0 1px var(--yasgui-line-soft, #edf0f4); + + svg { + width: 150px; + height: 90px; + overflow: visible; + } + } + + &__footer { + display: flex; + align-items: center; + gap: 16px; + padding: 14px 20px 14px 28px; + border-top: 1px solid var(--yasgui-line-soft, #edf0f4); + } + + &__dots { + display: flex; + align-items: center; + gap: 2px; + } + + &__dot { + width: 18px; + height: 18px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 50%; + background: transparent; + cursor: pointer; + + &::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 999px; + background: var(--yasgui-line, #dfe5ec); + transition: + width 0.15s ease, + background-color 0.15s ease; + } + + &:hover::before { + background: var(--yasgui-line-strong, #808893); + } + + &.active { + width: 26px; + border-radius: 999px; + + &::before { + width: 18px; + background: var(--yasgui-brand, #0563bb); + } + } + } + + &__guide { + display: inline-flex; + align-items: center; + gap: 6px; + margin-right: auto; + color: var(--yasgui-brand, #0563bb) !important; + font-weight: 500; + text-decoration: none; + + &:hover span { + text-decoration: underline; + } + } + + &__nav { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + } + + &__button { + height: 34px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 14px; + border: none; + border-radius: var(--yasgui-radius-button, 8px); + background: transparent; + color: var(--yasgui-ink-body, #3a4552); + font: inherit; + font-weight: 500; + cursor: pointer; + + &:hover { + background: var(--yasgui-surface-sunken, #edf1f6); + color: var(--yasgui-ink, #1c2633); + } + + &.primary { + padding: 0 12px 0 16px; + background: var(--yasgui-brand-fill, var(--yasgui-brand, #0563bb)); + color: var(--yasgui-on-brand, #ffffff); + + &:hover { + background: var(--yasgui-brand-fill-strong, var(--yasgui-brand-strong, #044a8c)); + color: var(--yasgui-on-brand, #ffffff); + } + } + + &.primary:not(:has(svg)) { + padding: 0 16px; + } + } + + button:focus-visible, + a:focus-visible { + outline: 2px solid var(--yasgui-focus, #044a8c); + outline-offset: 2px; + } + + // Illustration parts + .qs-card { + fill: var(--yasgui-surface-raised, #ffffff); + stroke: var(--yasgui-line, #dfe5ec); + stroke-width: 1; + } + .qs-raised { + filter: drop-shadow(0 2px 3px rgba(28, 38, 51, 0.1)); + } + .qs-darkCard { + fill: #141920; + stroke: var(--yasgui-line, #dfe5ec); + } + .qs-sunken, + .qs-sunkenFill { + fill: var(--yasgui-surface-sunken, #edf1f6); + } + .qs-kbd { + fill: var(--yasgui-surface-sunken, #edf1f6); + stroke: var(--yasgui-line, #dfe5ec); + } + .qs-text { + fill: var(--yasgui-line, #dfe5ec); + } + .qs-ink { + fill: var(--yasgui-ink-subtle, #6a7380); + } + .qs-line { + stroke: var(--yasgui-line-soft, #edf0f4); + stroke-width: 1; + } + .qs-stroke { + stroke: var(--yasgui-ink-subtle, #6a7380); + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + } + .qs-brand, + .qs-brandFill { + fill: var(--yasgui-brand-fill, var(--yasgui-brand, #0563bb)); + } + .qs-brandMid { + fill: var(--yasgui-brand, #0563bb); + opacity: 0.6; + } + .qs-brandSoft { + fill: var(--yasgui-brand-soft, #e3eefa); + } + .qs-brandSoftStrong { + fill: var(--yasgui-brand, #0563bb); + opacity: 0.3; + } + .qs-brandInk { + fill: var(--yasgui-brand, #0563bb); + } + .qs-brandStroke { + stroke: var(--yasgui-brand, #0563bb); + stroke-width: 1.5; + stroke-linecap: round; + } + .qs-onBrand, + .qs-onBrandBar { + fill: var(--yasgui-on-brand, #ffffff); + } + .qs-onBrandBar { + opacity: 0.85; + } + .qs-onBrandStroke { + stroke: var(--yasgui-on-brand, #ffffff); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + } + .qs-keyword { + fill: var(--qs-keyword); + } + .qs-iri { + fill: var(--qs-iri); + } + .qs-iriSoft { + fill: var(--yasgui-brand-soft, #e3eefa); + } + .qs-variable { + fill: var(--qs-variable); + } + .qs-caret { + fill: var(--yasgui-ink, #1c2633); + } + .qs-cursor { + fill: var(--yasgui-ink, #1c2633); + stroke: var(--yasgui-surface-raised, #ffffff); + stroke-width: 1; + stroke-linejoin: round; + } + .qs-folder { + fill: var(--qs-folder); + } + .qs-file { + fill: var(--yasgui-brand, #0563bb); + } + .qs-highlight { + fill: var(--yasgui-highlight, #f2b300); + } + .qs-highlightStroke { + stroke: var(--yasgui-highlight, #f2b300); + stroke-width: 2; + stroke-linecap: round; + } + .qs-moon { + fill: #c9d2dc; + } + + @media (max-width: 680px) { + align-items: flex-end; + padding: 12px; + + &__header { + padding: 20px 52px 0 20px; + } + + &__items { + grid-template-columns: minmax(0, 1fr); + gap: 14px; + padding: 16px 20px 20px; + } + + &__item { + flex-direction: row; + align-items: flex-start; + gap: 14px; + } + + &__illustration { + flex-shrink: 0; + width: 96px; + height: 64px; + + svg { + width: 84px; + height: 50px; + } + } + + &__footer { + flex-wrap: wrap; + padding: 12px 16px 12px 20px; + row-gap: 8px; + } + + &__guide { + order: 3; + width: 100%; + } + } +} diff --git a/packages/yasgui/src/QuickStart.ts b/packages/yasgui/src/QuickStart.ts new file mode 100644 index 00000000..57c9367f --- /dev/null +++ b/packages/yasgui/src/QuickStart.ts @@ -0,0 +1,587 @@ +import { addClass } from "@matdata/yasgui-utils"; +import { icon, modKeyLabel } from "./icons"; +import type Yasgui from "./"; +import "./QuickStart.scss"; + +/** Raise when the tour changes enough to show it again to users who saw an earlier one */ +const TOUR_VERSION = "1"; +const SEEN_KEY = "matgui_quickStart_seen"; + +interface TourItem { + title: string; + text: string; + /** Inline SVG drawn on a 120 x 72 canvas, coloured with the `qs-*` classes */ + illustration: string; +} + +interface TourPage { + title: string; + lead: string; + items: TourItem[]; +} + +// Small, schematic drawings of the interface. The classes map to theme tokens in QuickStart.scss. +const svg = (body: string) => + ``; +const card = (x: number, y: number, w: number, h: number, extra = "") => + ``; +const bar = (x: number, y: number, w: number, cls = "qs-text") => + ``; + +const ILLUSTRATIONS = { + endpoint: svg( + card(14, 8, 92, 18) + + `` + + bar(34, 15, 44, "qs-ink") + + `` + + card(14, 30, 92, 36, ' class="qs-card qs-raised"') + + `` + + bar(24, 38, 50, "qs-brandInk") + + bar(24, 54, 40), + ), + autocomplete: svg( + card(6, 6, 108, 60) + + bar(14, 14, 18, "qs-keyword") + + bar(35, 14, 30, "qs-iri") + + bar(14, 24, 10, "qs-variable") + + bar(27, 24, 16, "qs-ink") + + `` + + card(40, 32, 66, 30, ' class="qs-card qs-raised"') + + `` + + bar(47, 37, 36, "qs-brandInk") + + bar(47, 47, 44) + + bar(47, 55, 30), + ), + run: svg( + card(6, 14, 108, 44) + + `` + + `` + + bar(42, 34, 22, "qs-onBrandBar") + + `` + + bar(85, 34, 14), + ), + views: svg( + card(6, 6, 108, 60) + + `` + + `` + + bar(18, 16, 16, "qs-ink") + + bar(44, 16, 16) + + bar(66, 16, 16) + + bar(88, 16, 14) + + `` + + bar(16, 37, 20, "qs-iri") + + bar(48, 37, 22) + + bar(16, 47, 24, "qs-iri") + + bar(48, 47, 18) + + bar(80, 37, 20) + + bar(80, 47, 16), + ), + describe: svg( + card(6, 6, 108, 60) + + `` + + bar(14, 16, 34, "qs-iri") + + bar(14, 28, 40) + + bar(14, 40, 30) + + `` + + bar(71, 16, 28, "qs-ink") + + bar(71, 23, 20, "qs-brandInk") + + bar(71, 34, 34) + + bar(71, 42, 26) + + bar(71, 50, 30) + + ``, + ), + download: svg( + `` + + `` + + bar(42, 30, 28) + + bar(42, 38, 22) + + `` + + ``, + ), + tabs: svg( + `` + + `` + + `` + + bar(21, 23, 18, "qs-ink") + + bar(50, 23, 22) + + bar(78, 23, 16) + + `` + + card(10, 40, 100, 26) + + bar(16, 47, 20, "qs-keyword") + + bar(39, 47, 34, "qs-iri") + + bar(16, 56, 46), + ), + workspaces: svg( + card(10, 6, 100, 60) + + `` + + bar(41, 17, 36, "qs-ink") + + `` + + bar(49, 33, 30, "qs-ink") + + `` + + `` + + bar(49, 49, 32, "qs-brandInk"), + ), + share: svg( + card(6, 18, 108, 24) + + `` + + bar(32, 28, 54) + + `` + + `` + + `` + + bar(34, 54, 52, "qs-brandInk"), + ), + snippets: svg( + card(6, 6, 50, 18) + + `` + + bar(33, 13, 16, "qs-ink") + + card(6, 28, 108, 38, ' class="qs-card qs-raised"') + + `` + + bar(20, 36, 40, "qs-ink") + + `` + + bar(20, 47, 52, "qs-brandInk") + + bar(20, 58, 36), + ), + overview: svg( + card(6, 6, 108, 60) + + `` + + bar(22, 13, 40, "qs-ink") + + bar(12, 27, 30) + + `` + + bar(12, 39, 24) + + `` + + bar(12, 51, 28) + + ``, + ), + ai: svg( + `` + + bar(16, 16, 50, "qs-ink") + + card(30, 34, 82, 32, ' class="qs-card qs-raised"') + + `` + + bar(50, 42, 54, "qs-keyword") + + bar(50, 50, 40, "qs-iri") + + bar(38, 58, 52), + ), + hover: svg( + card(6, 40, 108, 26) + + bar(14, 48, 18, "qs-keyword") + + `` + + bar(36, 48, 36, "qs-iri") + + bar(14, 58, 50) + + card(22, 4, 76, 34, ' class="qs-card qs-raised"') + + bar(29, 11, 32, "qs-ink") + + bar(29, 19, 56) + + bar(29, 27, 40) + + ``, + ), + search: svg( + card(8, 14, 104, 22, ' class="qs-card qs-raised"') + + `` + + bar(32, 23, 40) + + `` + + bar(88, 23, 14) + + `` + + bar(14, 45, 46, "qs-brandInk") + + bar(14, 60, 60), + ), + keys: svg( + `` + + bar(18, 34, 14, "qs-ink") + + `` + + bar(53, 34, 14, "qs-ink") + + `` + + bar(88, 34, 14, "qs-onBrandBar"), + ), + sidePanel: svg( + card(6, 6, 108, 60) + + `` + + `` + + `` + + `` + + bar(27, 13, 26, "qs-ink") + + bar(27, 24, 30) + + bar(27, 32, 22) + + bar(27, 40, 28) + + bar(68, 13, 20, "qs-keyword") + + bar(68, 22, 36, "qs-iri") + + bar(68, 31, 28), + ), + settings: svg( + `` + + `` + + `` + + `` + + ``, + ), +}; + +export default class QuickStart { + private yasgui: Yasgui; + private overlayEl?: HTMLDivElement; + private dialogEl?: HTMLDivElement; + private pages: TourPage[] = []; + private pageIndex = 0; + private returnFocusEl?: HTMLElement; + + constructor(yasgui: Yasgui) { + this.yasgui = yasgui; + yasgui.helpMenu.addItem({ + id: "quickStart", + label: "How MatGUI works", + icon: "compass", + order: 10, + onClick: () => this.open(), + }); + } + + /** Whether this browser has seen the current version of the tour */ + public static hasBeenSeen(): boolean { + try { + return window.localStorage.getItem(SEEN_KEY) === TOUR_VERSION; + } catch { + // Without storage we can't remember a dismissal, so don't show the tour on every visit + return true; + } + } + + public static markSeen() { + try { + window.localStorage.setItem(SEEN_KEY, TOUR_VERSION); + } catch { + // ignore: storage is blocked or full + } + } + + public isOpen(): boolean { + return !!this.overlayEl && !this.overlayEl.hidden; + } + + public open(page = 0) { + QuickStart.markSeen(); + this.pages = this.buildPages(); + if (!this.overlayEl) this.draw(); + if (!this.overlayEl) return; + if (!this.isOpen() && document.activeElement instanceof HTMLElement) { + this.returnFocusEl = document.activeElement; + } + this.overlayEl.hidden = false; + document.addEventListener("keydown", this.handleKeydown, true); + this.showPage(page, false); + this.dialogEl?.focus(); + } + + public close() { + if (!this.overlayEl || this.overlayEl.hidden) return; + this.overlayEl.hidden = true; + document.removeEventListener("keydown", this.handleKeydown, true); + if (this.returnFocusEl?.isConnected) this.returnFocusEl.focus(); + this.returnFocusEl = undefined; + } + + public destroy() { + this.close(); + this.overlayEl?.remove(); + this.overlayEl = undefined; + this.yasgui.helpMenu.removeItem("quickStart"); + } + + public getPageCount(): number { + return this.buildPages().length; + } + + private buildPages(): TourPage[] { + const mod = modKeyLabel(); + const yasgui = this.yasgui; + return [ + { + title: "Write and run a query", + lead: "Every tab holds one SPARQL query and the endpoint it runs against.", + items: [ + { + title: "Pick an endpoint", + text: "Click the endpoint at the top of the card to switch, or type the URL of any SPARQL endpoint.", + illustration: ILLUSTRATIONS.endpoint, + }, + { + title: "Write with autocomplete", + text: `Prefixes, classes and properties are suggested while you type. Press Ctrl+Space to ask for them.`, + illustration: ILLUSTRATIONS.autocomplete, + }, + { + title: "Run it", + text: `Click Run or press ${mod}+Enter. While the query runs, the button turns into Stop.`, + illustration: ILLUSTRATIONS.run, + }, + ], + }, + { + title: "Explore the results", + lead: "Results show up below the editor, or next to it in the side-by-side layout.", + items: [ + { + title: "Switch views", + text: "See the results as a table, the raw response, or a map, graph or chart when the data fits.", + illustration: ILLUSTRATIONS.views, + }, + { + title: "Describe a resource", + text: "Ctrl+click an IRI anywhere to open its properties in the describe panel, then click on to follow links.", + illustration: ILLUSTRATIONS.describe, + }, + { + title: "Download the results", + text: "Each view has its own download, such as CSV for the table, in the toolbar above the results.", + illustration: ILLUSTRATIONS.download, + }, + ], + }, + { + title: "Keep your queries", + lead: "Your tabs, queries and results stay in this browser until you close them.", + items: [ + { + title: "Work in tabs", + text: "Click + in the top bar for a new query. Double-click the title of the card to rename it.", + illustration: ILLUSTRATIONS.tabs, + }, + { + title: "Save to a workspace", + text: "Keep queries in a Git repository or a SPARQL store and open them from the Queries view.", + illustration: ILLUSTRATIONS.workspaces, + }, + { + title: "Share a link", + text: "Share copies a link with the query and the endpoint, so others open exactly what you see.", + illustration: ILLUSTRATIONS.share, + }, + ], + }, + { + title: "Let MatGUI help", + lead: "You don't need to remember every prefix or the shape of the data.", + items: [ + { + title: "Insert snippets", + text: "Insert adds ready-made patterns, like FILTER or OPTIONAL, and Prefixes adds your declarations.", + illustration: ILLUSTRATIONS.snippets, + }, + { + title: "Look at the endpoint", + text: "The Endpoint view in the side panel lists the classes and properties the endpoint holds.", + illustration: ILLUSTRATIONS.overview, + }, + yasgui.aiAssistant + ? { + title: "Ask the AI assistant", + text: "Describe what you are looking for and get a query, or have a query explained or fixed.", + illustration: ILLUSTRATIONS.ai, + } + : { + title: "Hover an IRI", + text: "Hover an IRI in the editor to see its label, description and type.", + illustration: ILLUSTRATIONS.hover, + }, + ], + }, + { + title: "Find your way", + lead: "A few shortcuts get you anywhere in MatGUI.", + items: [ + yasgui.commandPalette + ? { + title: "Search everything", + text: `Press ${mod}+K to find saved queries, snippets, commands and settings.`, + illustration: ILLUSTRATIONS.search, + } + : { + title: "Use the shortcuts", + text: "Settings lists every keyboard shortcut, like F11 for a full screen editor.", + illustration: ILLUSTRATIONS.keys, + }, + { + title: "Open the side panel", + text: `Press ${mod}+B to show or hide your saved queries and the endpoint overview.`, + illustration: ILLUSTRATIONS.sidePanel, + }, + { + title: "Make it yours", + text: "Switch between light and dark, and manage endpoints, prefixes and the editor in Settings.", + illustration: ILLUSTRATIONS.settings, + }, + ], + }, + ]; + } + + private draw() { + const overlay = document.createElement("div"); + addClass(overlay, "yasgui-quickStart"); + overlay.hidden = true; + overlay.addEventListener("mousedown", (event) => { + if (event.target === overlay) this.close(); + }); + + const dialog = document.createElement("div"); + addClass(dialog, "yasgui-quickStart__dialog"); + dialog.setAttribute("role", "dialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("aria-labelledby", "yasgui-quickStart-title"); + dialog.tabIndex = -1; + this.dialogEl = dialog; + overlay.appendChild(dialog); + + // Inside the Yasgui root, so it gets the theme tokens + this.yasgui.rootEl.appendChild(overlay); + this.overlayEl = overlay; + } + + private showPage(index: number, focusNav = true) { + const dialog = this.dialogEl; + if (!dialog) return; + const count = this.pages.length; + this.pageIndex = Math.max(0, Math.min(index, count - 1)); + const page = this.pages[this.pageIndex]; + const last = this.pageIndex === count - 1; + const helpUrl = this.yasgui.config.helpUrl; + + dialog.innerHTML = ""; + + const header = document.createElement("div"); + addClass(header, "yasgui-quickStart__header"); + const eyebrow = document.createElement("div"); + addClass(eyebrow, "yasgui-quickStart__eyebrow"); + eyebrow.textContent = `How MatGUI works · ${this.pageIndex + 1} of ${count}`; + const title = document.createElement("h2"); + title.id = "yasgui-quickStart-title"; + addClass(title, "yasgui-quickStart__title"); + title.textContent = page.title; + const lead = document.createElement("p"); + addClass(lead, "yasgui-quickStart__lead"); + lead.textContent = page.lead; + const close = document.createElement("button"); + close.type = "button"; + addClass(close, "yasgui-quickStart__close"); + close.innerHTML = icon("x", 16); + close.title = "Close"; + close.setAttribute("aria-label", "Close the quick start"); + close.addEventListener("click", () => this.close()); + header.append(eyebrow, title, lead, close); + dialog.appendChild(header); + + const items = document.createElement("ul"); + addClass(items, "yasgui-quickStart__items"); + for (const item of page.items) { + const entry = document.createElement("li"); + addClass(entry, "yasgui-quickStart__item"); + const figure = document.createElement("div"); + addClass(figure, "yasgui-quickStart__illustration"); + figure.innerHTML = item.illustration; + const body = document.createElement("div"); + const heading = document.createElement("h3"); + heading.textContent = item.title; + const text = document.createElement("p"); + text.textContent = item.text; + body.append(heading, text); + entry.append(figure, body); + items.appendChild(entry); + } + dialog.appendChild(items); + + const footer = document.createElement("div"); + addClass(footer, "yasgui-quickStart__footer"); + + const dots = document.createElement("div"); + addClass(dots, "yasgui-quickStart__dots"); + this.pages.forEach((p, i) => { + const dot = document.createElement("button"); + dot.type = "button"; + addClass(dot, "yasgui-quickStart__dot"); + if (i === this.pageIndex) { + addClass(dot, "active"); + dot.setAttribute("aria-current", "step"); + } + dot.setAttribute("aria-label", `Page ${i + 1}: ${p.title}`); + dot.title = p.title; + dot.addEventListener("click", () => this.showPage(i)); + dots.appendChild(dot); + }); + footer.appendChild(dots); + + if (helpUrl) { + const guide = document.createElement("a"); + addClass(guide, "yasgui-quickStart__guide"); + guide.href = helpUrl; + guide.target = "_blank"; + guide.rel = "noopener noreferrer"; + guide.innerHTML = `Read the full user guide${icon("externalLink", 14)}`; + footer.appendChild(guide); + } + + const nav = document.createElement("div"); + addClass(nav, "yasgui-quickStart__nav"); + if (this.pageIndex > 0) { + const back = document.createElement("button"); + back.type = "button"; + addClass(back, "yasgui-quickStart__button", "yasgui-quickStart__back"); + back.textContent = "Back"; + back.addEventListener("click", () => this.showPage(this.pageIndex - 1)); + nav.appendChild(back); + } else { + const skip = document.createElement("button"); + skip.type = "button"; + addClass(skip, "yasgui-quickStart__button", "yasgui-quickStart__skip"); + skip.textContent = "Skip"; + skip.addEventListener("click", () => this.close()); + nav.appendChild(skip); + } + const next = document.createElement("button"); + next.type = "button"; + addClass(next, "yasgui-quickStart__button", "yasgui-quickStart__next", "primary"); + if (last) { + next.textContent = "Start querying"; + next.addEventListener("click", () => this.close()); + } else { + next.innerHTML = `Next${icon("chevronRight", 16, 2)}`; + next.addEventListener("click", () => this.showPage(this.pageIndex + 1)); + } + nav.appendChild(next); + footer.appendChild(nav); + dialog.appendChild(footer); + + if (focusNav) next.focus(); + } + + private handleKeydown = (event: KeyboardEvent) => { + if (!this.isOpen() || !this.dialogEl) return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.close(); + return; + } + if (event.key === "ArrowRight" || event.key === "ArrowLeft") { + // Only when no other control in the dialog wants the arrows (the dialog holds buttons and a link) + event.preventDefault(); + event.stopPropagation(); + this.showPage(this.pageIndex + (event.key === "ArrowRight" ? 1 : -1)); + return; + } + if (event.key === "Tab") { + // Keep the focus inside the dialog + const focusable = Array.from(this.dialogEl.querySelectorAll("button, a[href]")); + if (!focusable.length) return; + const first = focusable[0]; + const lastEl = focusable[focusable.length - 1]; + const active = document.activeElement; + if (event.shiftKey && (active === first || active === this.dialogEl)) { + event.preventDefault(); + lastEl.focus(); + } else if (!event.shiftKey && active === lastEl) { + event.preventDefault(); + first.focus(); + } else if (!this.dialogEl.contains(active)) { + event.preventDefault(); + first.focus(); + } + } + }; +} diff --git a/packages/yasgui/src/defaults.ts b/packages/yasgui/src/defaults.ts index 6c10af6b..acb591ed 100644 --- a/packages/yasgui/src/defaults.ts +++ b/packages/yasgui/src/defaults.ts @@ -32,6 +32,8 @@ export default function initialize(): Config { showThemeToggle: true, showBrand: true, showCommandPalette: true, + showQuickStart: true, + quickStartOnFirstVisit: true, orientation: "vertical", showSnippetsBar: false, iriHover: true, diff --git a/packages/yasgui/src/index.ts b/packages/yasgui/src/index.ts index 56c53d9c..58b1c1f6 100644 --- a/packages/yasgui/src/index.ts +++ b/packages/yasgui/src/index.ts @@ -27,6 +27,7 @@ import SparqlAnythingSupport from "./sparqlAnything/SparqlAnythingSupport"; import SidePanel from "./sidePanel/SidePanel"; import CommandPalette from "./CommandPalette"; import HelpMenu from "./HelpMenu"; +import QuickStart from "./QuickStart"; import WhatsNew from "./whatsNew/WhatsNew"; import AiAssistant from "./ai/AiAssistant"; import type { AiConfig } from "./ai/types"; @@ -120,6 +121,16 @@ export interface Config { * (default: true) */ showCommandPalette?: boolean; + /** + * "How MatGUI works": a short tour of five pages, in the help menu of the top bar (default: true). + * Set to false to leave it out. + */ + showQuickStart?: boolean; + /** + * Open the quick start tour the first time someone uses MatGUI in this browser (default: true). Only applies + * when `showQuickStart` is on. The dismissal is remembered in local storage. + */ + quickStartOnFirstVisit?: boolean; /** * Layout orientation: 'vertical' (default) or 'horizontal' (side-by-side) * 'vertical': YASQE on top, YASR below @@ -221,6 +232,8 @@ export class Yasgui extends EventEmitter { public commandPalette: CommandPalette | undefined; /** Menu of the help button in the top bar; add entries with `helpMenu.addItem()` */ public helpMenu: HelpMenu; + /** "How MatGUI works" tour, unless `showQuickStart` is false */ + public quickStart: QuickStart | undefined; /** What's new dialog (help menu and Ctrl+K search), unless `showWhatsNew` is false */ public whatsNew: WhatsNew | undefined; /** AI assistant (the "AI" view of the side panel), unless `ai.enabled` is false */ @@ -261,6 +274,7 @@ export class Yasgui extends EventEmitter { } this.helpMenu = new HelpMenu(this); + if (this.config.showQuickStart) this.quickStart = new QuickStart(this); if (this.config.showWhatsNew) this.whatsNew = new WhatsNew(this); this.tabElements = new TabElements(this); this.tabPanelsEl = document.createElement("div"); @@ -353,6 +367,14 @@ export class Yasgui extends EventEmitter { } else { this.sidePanel.restore(); } + if (this.quickStart && this.config.quickStartOnFirstVisit && !QuickStart.hasBeenSeen()) { + // After the first paint, so the tour opens over the interface it explains + const quickStart = this.quickStart; + QuickStart.markSeen(); + window.setTimeout(() => { + if (this.rootEl.isConnected) quickStart.open(); + }, 300); + } } public hasFullscreen(fullscreen: boolean) { if (fullscreen) { @@ -613,6 +635,7 @@ export class Yasgui extends EventEmitter { this.sparqlAnything?.destroy(); this.aiAssistant?.destroy(); this.commandPalette?.destroy(); + this.quickStart?.destroy(); this.whatsNew?.destroy(); this.helpMenu.destroy(); this.sidePanel.destroy(); diff --git a/test/quick-start-browser.ts b/test/quick-start-browser.ts new file mode 100644 index 00000000..05d12b41 --- /dev/null +++ b/test/quick-start-browser.ts @@ -0,0 +1,150 @@ +import * as path from "path"; +import * as http from "http"; +import * as chai from "chai"; +import * as puppeteer from "puppeteer"; +import { it, describe, before, beforeEach, after, afterEach } from "mocha"; +import { setup, destroy, closePage, getPage, wait } from "./utils.js"; + +const expect = chai.expect; + +describe("Quick start tour", function () { + let browser: puppeteer.Browser; + let page: puppeteer.Page; + let server: http.Server | undefined; + + before(async function () { + const refs = await setup(this, path.resolve("./build")); + browser = refs.browser; + server = refs.server; + }); + + beforeEach(async function () { + this.timeout(30000); + page = await getPage(browser, "yasgui.html", { quickStart: true }); + await page.evaluate(() => localStorage.clear()); + await page.reload({ waitUntil: "networkidle2" }); + await page.waitForFunction(() => "yasgui" in window); + }); + + afterEach(async () => { + await closePage(this, page); + }); + + after(async function () { + return destroy(browser, server); + }); + + const visibleTour = () => + page.evaluate(() => { + const overlay = document.querySelector(".yasgui-quickStart"); + return !!overlay && !overlay.hidden; + }); + const currentPage = () => + page.evaluate(() => ({ + title: document.querySelector(".yasgui-quickStart__title")?.textContent, + eyebrow: document.querySelector(".yasgui-quickStart__eyebrow")?.textContent, + items: document.querySelectorAll(".yasgui-quickStart__item").length, + illustrations: document.querySelectorAll(".yasgui-quickStart__illustration svg").length, + })); + + it("opens on the first visit and walks through five pages of three items", async function () { + await page.waitForSelector(".yasgui-quickStart:not([hidden])", { visible: true }); + const titles: string[] = []; + for (let i = 0; i < 5; i++) { + const state = await currentPage(); + expect(state.eyebrow).to.equal(`How MatGUI works · ${i + 1} of 5`); + expect(state.items).to.equal(3); + expect(state.illustrations).to.equal(3); + titles.push(state.title as string); + await page.click(".yasgui-quickStart__next"); + } + expect(titles[0]).to.equal("Write and run a query"); + expect(new Set(titles).size).to.equal(5); + // "Start querying" on the last page closes the tour + expect(await visibleTour()).to.equal(false); + }); + + it("goes back, jumps with the dots and the arrow keys, and closes with Escape", async function () { + await page.waitForSelector(".yasgui-quickStart:not([hidden])", { visible: true }); + await page.click(".yasgui-quickStart__next"); + await page.click(".yasgui-quickStart__back"); + expect((await currentPage()).eyebrow).to.contain("1 of 5"); + await page.click(".yasgui-quickStart__dot:nth-child(4)"); + expect((await currentPage()).eyebrow).to.contain("4 of 5"); + await page.keyboard.press("ArrowRight"); + expect((await currentPage()).eyebrow).to.contain("5 of 5"); + await page.keyboard.press("ArrowLeft"); + expect((await currentPage()).eyebrow).to.contain("4 of 5"); + await page.keyboard.press("Escape"); + expect(await visibleTour()).to.equal(false); + }); + + it("links to the user guide", async function () { + await page.waitForSelector(".yasgui-quickStart:not([hidden])", { visible: true }); + const link = await page.evaluate(() => { + const a = document.querySelector(".yasgui-quickStart__guide"); + return { href: a?.href, target: a?.target, helpUrl: (window as any).yasgui.config.helpUrl }; + }); + expect(link.href).to.equal(link.helpUrl); + expect(link.target).to.equal("_blank"); + }); + + it("does not open again once seen, but stays in the help menu", async function () { + await page.waitForSelector(".yasgui-quickStart:not([hidden])", { visible: true }); + await page.click(".yasgui-quickStart__close"); + await page.reload({ waitUntil: "networkidle2" }); + await wait(500); + expect(await visibleTour()).to.equal(false); + + await page.click(".yasgui-helpButton"); + const labels = await page.$$eval(".yasgui-helpMenu__item", (items) => items.map((i) => i.textContent?.trim())); + expect(labels[0]).to.equal("How MatGUI works"); + expect(labels[labels.length - 1]).to.equal("Documentation"); + await page.click(".yasgui-helpMenu__item"); + await page.waitForSelector(".yasgui-quickStart:not([hidden])", { visible: true }); + expect((await currentPage()).eyebrow).to.contain("1 of 5"); + }); + + it("can be left out with showQuickStart: false", async function () { + await page.evaluate(() => { + localStorage.clear(); + const Yasgui = (window as any).Yasgui; + const div = document.createElement("div"); + document.body.appendChild(div); + (window as any).other = new Yasgui(div, { showQuickStart: false, persistenceId: "noQuickStart" }); + }); + await wait(600); + const result = await page.evaluate(() => { + const other = (window as any).other; + return { + tour: !!other.rootEl.querySelector(".yasgui-quickStart"), + quickStart: other.quickStart, + items: other.helpMenu.getItems().map((item: any) => item.id), + }; + }); + expect(result.tour).to.equal(false); + expect(result.quickStart).to.equal(undefined); + expect(result.items).to.not.include("quickStart"); + }); + + it("can stay in the help menu without opening on the first visit", async function () { + await page.evaluate(() => { + localStorage.clear(); + const Yasgui = (window as any).Yasgui; + const div = document.createElement("div"); + document.body.appendChild(div); + (window as any).other = new Yasgui(div, { quickStartOnFirstVisit: false, persistenceId: "manualQuickStart" }); + }); + await wait(600); + const result = await page.evaluate(() => { + const other = (window as any).other; + const overlay = other.rootEl.querySelector(".yasgui-quickStart"); + return { + open: !!overlay && !overlay.hidden, + items: other.helpMenu.getItems().map((item: any) => item.id), + }; + }); + expect(result.open).to.equal(false); + expect(result.items[0]).to.equal("quickStart"); + }); +}); diff --git a/test/run.ts b/test/run.ts index e06b6de6..c0875b86 100644 --- a/test/run.ts +++ b/test/run.ts @@ -11,6 +11,7 @@ import Yasqe from "@matdata/yasqe"; import { setup, destroy, closePage, getPage, makeScreenshot, inspectLive, wait } from "./utils.js"; import "./endpoint-describe-browser.js"; import "./iri-hover-browser.js"; +import "./quick-start-browser.js"; import "./ai-assistant-browser.js"; import "./sparql-anything-browser.js"; import "./whats-new-browser.js"; diff --git a/test/utils.ts b/test/utils.ts index 513b392d..308080e4 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -85,8 +85,18 @@ export async function destroy(browser: Browser, server?: http.Server) { if (browser) await browser.close(); if (server) server.close(); } -export async function getPage(browser: Browser, path: string) { +export async function getPage(browser: Browser, path: string, options: { quickStart?: boolean } = {}) { const page = await browser.newPage(); + // The quick start tour opens on the first visit and would cover the page; tests that want it pass quickStart + if (!options.quickStart) { + await page.evaluateOnNewDocument(() => { + try { + localStorage.setItem("matgui_quickStart_seen", "1"); + } catch { + // ignore + } + }); + } await page.goto(`http://localhost:${PORT}/${path}?noExternalServices=true`, { waitUntil: "networkidle2", timeout: 30000,