From f4cb0d9b12e53b664d16bf9cf82566150745cab1 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 28 Oct 2026 21:31:06 +0000 Subject: [PATCH 1/3] feat(yasgui): help menu behind the help button of the top bar The help button opens a small menu with the documentation and any entries added with yasgui.helpMenu.addItem() (quick start, what's new). With one entry the button runs it directly, so the default stays one click. --- packages/yasgui/src/HelpMenu.scss | 84 +++++++++++++ packages/yasgui/src/HelpMenu.ts | 196 +++++++++++++++++++++++++++++ packages/yasgui/src/TabElements.ts | 12 +- packages/yasgui/src/index.ts | 8 +- 4 files changed, 289 insertions(+), 11 deletions(-) create mode 100644 packages/yasgui/src/HelpMenu.scss create mode 100644 packages/yasgui/src/HelpMenu.ts diff --git a/packages/yasgui/src/HelpMenu.scss b/packages/yasgui/src/HelpMenu.scss new file mode 100644 index 00000000..1000000e --- /dev/null +++ b/packages/yasgui/src/HelpMenu.scss @@ -0,0 +1,84 @@ +// `.yasgui > div` lays out the tab panels as a column; the help menu is not one of them +.yasgui > div.yasgui-helpMenu:not(.tabsList) { + flex-direction: column; + overflow: visible; + min-height: 0; + + &[hidden] { + display: none; + } +} + +.yasgui .yasgui-helpButton { + position: relative; +} + +.yasgui-helpButton__badge { + position: absolute; + top: 6px; + right: 6px; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--yasgui-brand, #0563bb); + box-shadow: 0 0 0 2px var(--yasgui-surface, #ffffff); + pointer-events: none; +} + +// Dropdown of the help button +.yasgui-helpMenu { + position: fixed; + z-index: 10040; + display: flex; + min-width: 200px; + padding: 4px; + box-sizing: border-box; + border-radius: 10px; + background: var(--yasgui-surface-raised, #ffffff); + box-shadow: var(--yasgui-shadow-overlay, 0 24px 48px rgba(13, 20, 28, 0.18), 0 0 0 1px rgba(13, 20, 28, 0.06)); + font-family: var(--yasgui-font-family, inherit); + font-size: 13px; + color: var(--yasgui-ink, #1c2633); + + &[hidden] { + display: none; + } + + &__item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: 32px; + padding: 0 10px; + border: none; + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + + .yasgui-icon { + flex-shrink: 0; + color: var(--yasgui-ink-subtle, #6a7380); + } + + span:first-of-type { + flex: 1; + } + + &:hover, + &:focus-visible { + outline: none; + background: var(--yasgui-surface-sunken, #edf0f4); + } + } + + &__badge { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--yasgui-brand, #0563bb); + } +} diff --git a/packages/yasgui/src/HelpMenu.ts b/packages/yasgui/src/HelpMenu.ts new file mode 100644 index 00000000..922843a6 --- /dev/null +++ b/packages/yasgui/src/HelpMenu.ts @@ -0,0 +1,196 @@ +import { addClass } from "@matdata/yasgui-utils"; +import { icon, IconName } from "./icons"; +import type Yasgui from "./"; +import "./HelpMenu.scss"; + +export interface HelpMenuItem { + /** Unique id, used to replace, remove or badge the item later */ + id: string; + label: string; + icon?: IconName; + onClick: () => void; + /** Show a dot on the item and on the help button, e.g. for unseen release notes */ + badge?: boolean; + /** Items are sorted on this number (default 100); built-in items use 10, 20 and 90 */ + order?: number; +} + +/** + * Menu of the help button in the top bar: the quick start tour, what's new and the documentation. + * The button only shows when the menu has an item; with a single item it runs that item directly. + */ +export default class HelpMenu { + private yasgui: Yasgui; + private items: HelpMenuItem[] = []; + private buttonEl?: HTMLButtonElement; + private menuEl?: HTMLDivElement; + + constructor(yasgui: Yasgui) { + this.yasgui = yasgui; + const helpUrl = yasgui.config.helpUrl; + if (helpUrl) { + this.addItem({ + id: "documentation", + label: "Documentation", + icon: "externalLink", + order: 90, + onClick: () => window.open(helpUrl, "_blank", "noopener,noreferrer"), + }); + } + } + + /** Add an item, or replace the item with the same id */ + public addItem(item: HelpMenuItem) { + this.items = this.items.filter((existing) => existing.id !== item.id); + this.items.push(item); + this.items.sort((a, b) => (a.order ?? 100) - (b.order ?? 100)); + this.update(); + } + + public removeItem(id: string) { + this.items = this.items.filter((item) => item.id !== id); + this.update(); + } + + public setBadge(id: string, badge: boolean) { + const item = this.items.find((existing) => existing.id === id); + if (!item || !!item.badge === badge) return; + item.badge = badge; + this.update(); + } + + public getItems(): HelpMenuItem[] { + return [...this.items]; + } + + /** The help button of the top bar, kept up to date with the items */ + public getButton(): HTMLButtonElement { + if (this.buttonEl) return this.buttonEl; + const button = document.createElement("button"); + button.type = "button"; + button.className = "yasgui-topBarButton yasgui-helpButton"; + button.setAttribute("aria-haspopup", "menu"); + button.setAttribute("aria-expanded", "false"); + button.addEventListener("click", (event) => { + event.stopPropagation(); + if (this.items.length === 1) { + this.items[0].onClick(); + return; + } + if (this.isOpen()) this.close(); + else this.open(); + }); + this.buttonEl = button; + this.update(); + return button; + } + + public isOpen(): boolean { + return !!this.menuEl && !this.menuEl.hidden; + } + + public open() { + if (!this.buttonEl || !this.items.length) return; + if (!this.menuEl) this.drawMenu(); + const menu = this.menuEl!; + this.renderMenu(); + menu.hidden = false; + this.buttonEl.setAttribute("aria-expanded", "true"); + const rect = this.buttonEl.getBoundingClientRect(); + menu.style.top = `${rect.bottom + 6}px`; + menu.style.right = `${Math.max(8, document.documentElement.clientWidth - rect.right)}px`; + (menu.querySelector("button") as HTMLButtonElement | null)?.focus(); + document.addEventListener("mousedown", this.handleOutside, true); + document.addEventListener("keydown", this.handleKeydown, true); + } + + public close(returnFocus = false) { + if (!this.menuEl || this.menuEl.hidden) return; + this.menuEl.hidden = true; + this.buttonEl?.setAttribute("aria-expanded", "false"); + document.removeEventListener("mousedown", this.handleOutside, true); + document.removeEventListener("keydown", this.handleKeydown, true); + if (returnFocus) this.buttonEl?.focus(); + } + + public destroy() { + this.close(); + this.menuEl?.remove(); + this.menuEl = undefined; + } + + private handleOutside = (event: MouseEvent) => { + const target = event.target as Node | null; + if (target && (this.menuEl?.contains(target) || this.buttonEl?.contains(target))) return; + this.close(); + }; + + private handleKeydown = (event: KeyboardEvent) => { + if (!this.menuEl) return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.close(true); + return; + } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + const buttons = Array.from(this.menuEl.querySelectorAll("button")); + const current = buttons.indexOf(document.activeElement as HTMLButtonElement); + const next = (current + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length; + buttons[next]?.focus(); + }; + + private drawMenu() { + const menu = document.createElement("div"); + addClass(menu, "yasgui-helpMenu"); + menu.setAttribute("role", "menu"); + menu.hidden = true; + // Inside the Yasgui root, so it gets the theme tokens + this.yasgui.rootEl.appendChild(menu); + this.menuEl = menu; + } + + private renderMenu() { + if (!this.menuEl) return; + this.menuEl.innerHTML = ""; + for (const item of this.items) { + const entry = document.createElement("button"); + entry.type = "button"; + entry.setAttribute("role", "menuitem"); + addClass(entry, "yasgui-helpMenu__item"); + entry.innerHTML = `${item.icon ? icon(item.icon, 15) : ""}`; + entry.querySelector("span")!.textContent = item.label; + if (item.badge) { + const dot = document.createElement("span"); + addClass(dot, "yasgui-helpMenu__badge"); + dot.setAttribute("aria-label", "New"); + entry.appendChild(dot); + } + entry.addEventListener("click", () => { + this.close(); + item.onClick(); + }); + this.menuEl.appendChild(entry); + } + } + + private update() { + const button = this.buttonEl; + if (!button) return; + button.hidden = this.items.length === 0; + const single = this.items.length === 1 ? this.items[0] : undefined; + const badge = this.items.some((item) => item.badge); + const label = single?.id === "documentation" ? "Open the documentation" : single ? single.label : "Help"; + button.title = badge ? `${label} (new)` : label; + button.setAttribute("aria-label", badge ? `${label}, new` : label); + button.setAttribute("aria-haspopup", single ? "false" : "menu"); + button.innerHTML = icon("help", 16); + if (badge) { + const dot = document.createElement("span"); + addClass(dot, "yasgui-helpButton__badge"); + button.appendChild(dot); + } + if (this.isOpen()) this.renderMenu(); + } +} diff --git a/packages/yasgui/src/TabElements.ts b/packages/yasgui/src/TabElements.ts index 85c72119..2ab94ba2 100644 --- a/packages/yasgui/src/TabElements.ts +++ b/packages/yasgui/src/TabElements.ts @@ -432,16 +432,8 @@ export class TabList { actions.appendChild(search); } - const helpUrl = this.yasgui.config.helpUrl; - if (helpUrl) { - const help = iconButton("help", "Help", { - className: "yasgui-topBarButton yasgui-helpButton", - title: "Open the documentation", - size: 16, - }); - help.addEventListener("click", () => window.open(helpUrl, "_blank", "noopener,noreferrer")); - actions.appendChild(help); - } + // Quick start, what's new and the documentation; hidden while the menu has no items + actions.appendChild(this.yasgui.helpMenu.getButton()); if (this.yasgui.config.showThemeToggle) { const theme = document.createElement("button"); diff --git a/packages/yasgui/src/index.ts b/packages/yasgui/src/index.ts index 4cd875ba..ec152da1 100644 --- a/packages/yasgui/src/index.ts +++ b/packages/yasgui/src/index.ts @@ -26,6 +26,7 @@ import EndpointDescribePanel, { EndpointDescribeConfig } from "./endpointDescrib import SparqlAnythingSupport from "./sparqlAnything/SparqlAnythingSupport"; import SidePanel from "./sidePanel/SidePanel"; import CommandPalette from "./CommandPalette"; +import HelpMenu from "./HelpMenu"; import AiAssistant from "./ai/AiAssistant"; import type { AiConfig } from "./ai/types"; import "./index.scss"; @@ -130,7 +131,8 @@ export interface Config { */ showSnippetsBar?: boolean; /** - * Link opened by the help button in the top bar. Set to false to hide the button. + * Documentation link in the menu of the help button in the top bar. Set to false to leave it out (the button + * hides when its menu has no other entries, such as the quick start tour or what's new). */ helpUrl?: string | false; /** @@ -212,6 +214,8 @@ export class Yasgui extends EventEmitter { public sidePanel: SidePanel; /** Ctrl+K search for saved queries, commands and settings (unless `showCommandPalette` is false) */ public commandPalette: CommandPalette | undefined; + /** Menu of the help button in the top bar; add entries with `helpMenu.addItem()` */ + public helpMenu: HelpMenu; /** AI assistant (the "AI" view of the side panel), unless `ai.enabled` is false */ public aiAssistant: AiAssistant | undefined; /** SPARQL Anything support (the "Source" view of the side panel), unless `sparqlAnything` is false */ @@ -249,6 +253,7 @@ export class Yasgui extends EventEmitter { this.config.orientation = persistedOrientation; } + this.helpMenu = new HelpMenu(this); this.tabElements = new TabElements(this); this.tabPanelsEl = document.createElement("div"); addClass(this.tabPanelsEl, "yasgui-tabPanels"); @@ -600,6 +605,7 @@ export class Yasgui extends EventEmitter { this.sparqlAnything?.destroy(); this.aiAssistant?.destroy(); this.commandPalette?.destroy(); + this.helpMenu.destroy(); this.sidePanel.destroy(); this.tabElements.destroy(); for (const tabId in this._tabs) { From b03dc6195d4b09f3dda6011d9541d0b7cda9463f Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 28 Oct 2026 21:40:30 +0000 Subject: [PATCH 2/3] feat(yasgui): add a "How MatGUI works" quick start tour Five pages with three items each, a small illustration and a short explanation. Opens on the first visit (remembered in local storage), stays available from the help menu and the Ctrl+K search, and links to the user guide. Embedders can set showQuickStart: false, or quickStartOnFirstVisit: false to only offer it in the help menu. --- .changeset/quick-start-tour.md | 5 + docs/developer-guide.md | 7 + docs/user-guide.md | 19 +- packages/yasgui/src/CommandPalette.ts | 12 + packages/yasgui/src/QuickStart.scss | 423 +++++++++++++++++++ packages/yasgui/src/QuickStart.ts | 587 ++++++++++++++++++++++++++ packages/yasgui/src/defaults.ts | 2 + packages/yasgui/src/index.ts | 23 + test/quick-start-browser.ts | 150 +++++++ test/run.ts | 1 + test/utils.ts | 12 +- 11 files changed, 1238 insertions(+), 3 deletions(-) create mode 100644 .changeset/quick-start-tour.md create mode 100644 packages/yasgui/src/QuickStart.scss create mode 100644 packages/yasgui/src/QuickStart.ts create mode 100644 test/quick-start-browser.ts 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 e5b13c67..147f9e62 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 12a5cc40..df635b61 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) @@ -149,6 +150,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. @@ -352,7 +367,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 MatGUI user guide in a new browser tab +- **Help** (?): opens a menu with **How MatGUI works** (the [quick start tour](#quick-start-tour)) and **Documentation**, the MatGUI user guide in a new browser tab - **Theme** (sun / moon): switches between the light and dark theme - **Settings** (gear): opens the settings of the active tab @@ -362,7 +377,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 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 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. diff --git a/packages/yasgui/src/CommandPalette.ts b/packages/yasgui/src/CommandPalette.ts index 50d9a952..2cb9bc17 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.config.helpUrl) { const helpUrl = yasgui.config.helpUrl; commands.push({ 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..9e2bfdeb --- /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 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 e1506917..5ccbc5c3 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 ec152da1..4397650f 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 AiAssistant from "./ai/AiAssistant"; import type { AiConfig } from "./ai/types"; import "./index.scss"; @@ -119,6 +120,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 @@ -216,6 +227,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; /** AI assistant (the "AI" view of the side panel), unless `ai.enabled` is false */ public aiAssistant: AiAssistant | undefined; /** SPARQL Anything support (the "Source" view of the side panel), unless `sparqlAnything` is false */ @@ -254,6 +267,7 @@ export class Yasgui extends EventEmitter { } this.helpMenu = new HelpMenu(this); + if (this.config.showQuickStart) this.quickStart = new QuickStart(this); this.tabElements = new TabElements(this); this.tabPanelsEl = document.createElement("div"); addClass(this.tabPanelsEl, "yasgui-tabPanels"); @@ -345,6 +359,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) { @@ -605,6 +627,7 @@ export class Yasgui extends EventEmitter { this.sparqlAnything?.destroy(); this.aiAssistant?.destroy(); this.commandPalette?.destroy(); + this.quickStart?.destroy(); this.helpMenu.destroy(); this.sidePanel.destroy(); this.tabElements.destroy(); diff --git a/test/quick-start-browser.ts b/test/quick-start-browser.ts new file mode 100644 index 00000000..e17a6d20 --- /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.deep.equal(["documentation"]); + }); + + 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).to.deep.equal(["quickStart", "documentation"]); + }); +}); diff --git a/test/run.ts b/test/run.ts index 91e3494e..d4f7ab69 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"; 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, From e5f73939d59a552d8d625a6b56a46e1bbb657db2 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Thu, 29 Oct 2026 14:48:01 +0100 Subject: [PATCH 3/3] chore: small text addition --- packages/yasgui/src/QuickStart.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/yasgui/src/QuickStart.ts b/packages/yasgui/src/QuickStart.ts index 9e2bfdeb..57c9367f 100644 --- a/packages/yasgui/src/QuickStart.ts +++ b/packages/yasgui/src/QuickStart.ts @@ -324,7 +324,7 @@ export default class QuickStart { }, { title: "Describe a resource", - text: "Ctrl+click an IRI to open its properties in the describe panel, then click on to follow links.", + text: "Ctrl+click an IRI anywhere to open its properties in the describe panel, then click on to follow links.", illustration: ILLUSTRATIONS.describe, }, {