diff --git a/demo/index.html b/demo/index.html index 5b14c91..f4a04a6 100644 --- a/demo/index.html +++ b/demo/index.html @@ -35,17 +35,19 @@ -
-

VMK UI Kit

-

Demo page placeholder. Component sections will be added here.

+
+
+

VMK UI Kit

+

Demo page placeholder. Component sections will be added here.

- @@include('./partials/typography.html') + @@include('./partials/typography.html') @@include('./partials/palette.html') @@include('./partials/utilities.html') @@include('./partials/buttons.html') @@include('./partials/choices.html') @@include('./partials/select-textarea.html') @@include('./partials/inputs.html') + @@include('./partials/input-group.html') @@include('./partials/cards.html') @@include('./partials/badges.html') @@include('./partials/alerts.html') @@ -59,6 +61,9 @@ @@include('./partials/chips.html') @@include('./partials/progress.html') @@include('./partials/tables.html') + @@include('./partials/description-list.html') + @@include('./partials/empty-state.html') + @@include('./partials/page-header.html') @@include('./partials/lists.html') @@include('./partials/skeleton.html') @@include('./partials/tooltips.html') @@ -75,8 +80,9 @@ @@include('./partials/popover.html') @@include('./partials/pagination.html') @@include('./partials/timeline.html') - @@include('./partials/loader.html') -
+ @@include('./partials/loader.html') +
+ diff --git a/demo/partials/description-list.html b/demo/partials/description-list.html new file mode 100644 index 0000000..af2860c --- /dev/null +++ b/demo/partials/description-list.html @@ -0,0 +1,31 @@ +
+

Description List

+ +
+
+
+ + + + +
diff --git a/demo/partials/drawer.html b/demo/partials/drawer.html index 4c075c0..27edb5b 100644 --- a/demo/partials/drawer.html +++ b/demo/partials/drawer.html @@ -2,45 +2,23 @@

Drawers

- -
- - - + diff --git a/demo/partials/empty-state.html b/demo/partials/empty-state.html new file mode 100644 index 0000000..ccd2d8d --- /dev/null +++ b/demo/partials/empty-state.html @@ -0,0 +1,30 @@ +
+

Empty State

+ +
+
+
+ + + + +
diff --git a/demo/partials/input-group.html b/demo/partials/input-group.html new file mode 100644 index 0000000..96e9b89 --- /dev/null +++ b/demo/partials/input-group.html @@ -0,0 +1,27 @@ +
+

Input Group

+ +
+
+
+ + + + +
diff --git a/demo/partials/modals.html b/demo/partials/modals.html index bc13e75..b1aa56c 100644 --- a/demo/partials/modals.html +++ b/demo/partials/modals.html @@ -2,29 +2,25 @@

Modals

-
- + diff --git a/demo/partials/page-header.html b/demo/partials/page-header.html new file mode 100644 index 0000000..2d87811 --- /dev/null +++ b/demo/partials/page-header.html @@ -0,0 +1,33 @@ +
+

Page Header

+ +
+
+
+ + + + +
diff --git a/demo/partials/toasts.html b/demo/partials/toasts.html index e57beb0..faea077 100644 --- a/demo/partials/toasts.html +++ b/demo/partials/toasts.html @@ -2,16 +2,16 @@

Toasts

- - - -
diff --git a/gulpfile.js b/gulpfile.js index 90129da..070824e 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -81,32 +81,63 @@ } function scripts() { - return esbuild.build({ - entryPoints: [paths.scripts.entry], - bundle: true, - minify: true, - sourcemap: true, - outfile: paths.scripts.outfile, - target: ["es2018"], - platform: "browser", - format: "iife", - globalName: "VMKUIKit" - }).then(() => { + const vmkOutfile = paths.scripts.outfile; + const gnexusOutfile = vmkOutfile.replace("/vmk-ui-kit.js", "/gnexus-ui-kit.js"); + + return Promise.all([ + esbuild.build({ + entryPoints: [paths.scripts.entry], + bundle: true, + minify: true, + sourcemap: true, + outfile: vmkOutfile, + target: ["es2018"], + platform: "browser", + format: "iife", + globalName: "VMKUIKit" + }), + esbuild.build({ + entryPoints: [paths.scripts.entry], + bundle: true, + minify: true, + sourcemap: true, + outfile: gnexusOutfile, + target: ["es2018"], + platform: "browser", + format: "iife", + globalName: "GNexusUIKit" + }) + ]).then(() => { browserSync.reload(); }); } function scriptsEsm() { - return esbuild.build({ - entryPoints: [paths.scripts.entry], - bundle: true, - minify: true, - sourcemap: true, - outfile: paths.scripts.esmOutfile, - target: ["es2018"], - platform: "browser", - format: "esm" - }).then(() => { + const vmkOutfile = paths.scripts.esmOutfile; + const gnexusOutfile = vmkOutfile.replace("/vmk-ui-kit.esm.js", "/gnexus-ui-kit.esm.js"); + + return Promise.all([ + esbuild.build({ + entryPoints: [paths.scripts.entry], + bundle: true, + minify: true, + sourcemap: true, + outfile: vmkOutfile, + target: ["es2018"], + platform: "browser", + format: "esm" + }), + esbuild.build({ + entryPoints: [paths.scripts.entry], + bundle: true, + minify: true, + sourcemap: true, + outfile: gnexusOutfile, + target: ["es2018"], + platform: "browser", + format: "esm" + }) + ]).then(() => { browserSync.reload(); }); } diff --git a/package.json b/package.json index 0d030d1..7956158 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,8 @@ "version": "0.1.0", "description": "VMK UI Kit — Figma-based design system compatible with the gnexus-ui-kit API contract.", "license": "UNLICENSED", - "main": "dist/js/vmk-ui-kit.js", - "module": "dist/js/vmk-ui-kit.esm.js", + "main": "dist/js/gnexus-ui-kit.js", + "module": "dist/js/gnexus-ui-kit.esm.js", "style": "dist/css/kit.css", "sideEffects": [ "dist/css/*.css", @@ -20,6 +20,11 @@ ], "exports": { ".": { + "import": "./dist/js/gnexus-ui-kit.esm.js", + "require": "./dist/js/gnexus-ui-kit.js", + "default": "./dist/js/gnexus-ui-kit.js" + }, + "./vmk": { "import": "./dist/js/vmk-ui-kit.esm.js", "require": "./dist/js/vmk-ui-kit.js", "default": "./dist/js/vmk-ui-kit.js" diff --git a/src/js/components/drawer.js b/src/js/components/drawer.js new file mode 100644 index 0000000..352a371 --- /dev/null +++ b/src/js/components/drawer.js @@ -0,0 +1,127 @@ +/** + * Drawer browser helper. + * + * Mirrors gnexus-ui-kit Drawer public surface: + * create(id, props) + */ + +function appendContent(container, content, mode = "html") { + if (content instanceof Node) { + container.append(content); + return; + } + + if (typeof content !== "undefined" && content !== null) { + if (mode === "text") { + container.textContent = content; + } else { + container.innerHTML = content; + } + } +} + +function template(id, title, position) { + const drawer = document.createElement("div"); + drawer.className = `drawer drawer-${position}`; + drawer.setAttribute("aria-hidden", "true"); + drawer.id = id; + + const backdrop = document.createElement("div"); + backdrop.className = "drawer-backdrop"; + + const panel = document.createElement("aside"); + panel.className = "drawer-panel"; + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-modal", "true"); + panel.setAttribute("aria-labelledby", `${id}-title`); + + const header = document.createElement("header"); + header.className = "drawer-header"; + + const drawerTitle = document.createElement("h4"); + drawerTitle.className = "drawer-title"; + drawerTitle.id = `${id}-title`; + drawerTitle.textContent = title; + + const close = document.createElement("button"); + close.className = "drawer-close"; + close.type = "button"; + close.setAttribute("aria-label", "Close"); + close.textContent = "✕"; + + const body = document.createElement("div"); + body.className = "drawer-body"; + + header.append(drawerTitle, close); + panel.append(header, body); + drawer.append(backdrop, panel); + + return drawer; +} + +function init(drawer, onready) { + drawer.show = function () { + document.querySelector("body").append(drawer); + setTimeout(() => { + drawer.classList.add("a-show"); + }, 10); + }; + + drawer.close = function () { + drawer.classList.add("a-hide"); + setTimeout(() => { + drawer.remove(); + }, 300); + }; + + drawer.querySelector(".drawer-close").addEventListener("click", () => { + drawer.close(); + }); + + drawer.querySelector(".drawer-backdrop")?.addEventListener("click", () => { + drawer.close(); + }); + + if (typeof onready === "function") { + onready(drawer); + } + + return drawer; +} + +/** + * Create a new drawer panel. + * @param {string} id - Unique id + * @param {object} props - { title, position: "right"|"left", body, bodyText, bodyHtml, footer, onready } + * @returns {HTMLElement} Drawer DOM element with show()/close() + */ +function create(id, props = {}) { + const title = props.title || ""; + const position = props.position === "left" ? "left" : "right"; + const footer = props.footer || ""; + + const drawer = template(id, title, position); + const drawerBody = drawer.querySelector(".drawer-body"); + + if (typeof props.body === "function") { + const bodyResult = props.body(drawer); + appendContent(drawerBody, bodyResult, props.bodyMode ?? "html"); + } else if (typeof props.bodyText !== "undefined") { + appendContent(drawerBody, props.bodyText, "text"); + } else if (typeof props.bodyHtml !== "undefined") { + appendContent(drawerBody, props.bodyHtml, "html"); + } + + if (footer) { + const drawerFooter = document.createElement("footer"); + drawerFooter.className = "drawer-footer"; + appendContent(drawerFooter, footer); + drawer.querySelector(".drawer-panel").append(drawerFooter); + } + + return init(drawer, props?.onready); +} + +export default { + create +}; diff --git a/src/js/components/helper.js b/src/js/components/helper.js new file mode 100644 index 0000000..f4e1e96 --- /dev/null +++ b/src/js/components/helper.js @@ -0,0 +1,300 @@ +/** + * Helper compatibility object. + * + * Mirrors the public surface of gnexus-ui-kit/src/js/components/helper.js: + * template, unification, states + */ + +function sidebarNav(items) { + let listItems = ""; + + for (const item of items) { + let aOpen = ""; + let aClose = ""; + if (item.route) { + aOpen = ``; + aClose = ""; + } + + listItems += ` +
  • + ${aOpen}${item.content}${aClose} +
  • + `; + } + + return ` + + `; +} + +function table(caption, columns, data, tfoot) { + let head = ``; + let totalColumns = 0; + for (const key in columns) { + head += `${columns[key]}`; + totalColumns++; + } + head += ""; + + let body = ""; + for (const item of data) { + body += ``; + for (const column in columns) { + body += `${item[column]}`; + } + body += ``; + } + + let foot = ""; + if (typeof tfoot !== "undefined") { + foot = ` + + + + ${tfoot} + + + + `; + } + + const tableCaption = caption ? `${caption}` : ""; + const tableHead = data.length ? `${head}` : ""; + + body = data.length ? body : `Empty`; + const tableEmptyClass = !data.length ? "table-empty" : ""; + + return ` +
    + + ${tableCaption} + ${tableHead} + ${body} + ${foot} +
    +
    + `; +} + +function createElement(type, props, content) { + const node = document.createElement(type); + + for (const [key, value] of Object.entries(props || {})) { + if (key === "class") { + node.className = value; + } else if (key === "dataset") { + Object.assign(node.dataset, value); + } else { + node.setAttribute(key, value); + } + } + + node.innerHTML = typeof content !== "undefined" ? content : ""; + return node; +} + +function createAlert(type, content) { + const normalizedType = type === "error" ? "danger" : type; + if (["primary", "secondary", "accent", "success", "info", "warning", "danger"].indexOf(normalizedType) < 0) { + return console.error("createAlert()", "Error of type: " + type); + } + + return createElement("div", { class: `alert alert-${normalizedType}` }, content); +} + +function fieldsUnification(data, map = {}) { + const dataObj = {}; + + for (const field in data) { + if (typeof map[field] !== "undefined") { + dataObj[map[field]] = data[field]; + continue; + } + dataObj[field] = data[field]; + } + + return dataObj; +} + +function btnLoadingState(btn, isLoading) { + if (btn?.isLoading === isLoading) { + return false; + } + + if (isLoading) { + btn.isLoading = true; + btn.originalContent = btn.innerHTML; + if (btn.classList.contains("with-icon")) { + btn.originalWithIcon = true; + } else { + btn.classList.add("with-icon"); + } + + btn.classList.add("loading-state"); + btn.setAttribute("disabled", "disabled"); + btn.innerHTML = ` Loading`; + } else { + btn.isLoading = false; + if (!btn.originalContent) { + return false; + } + btn.removeAttribute("disabled"); + btn.classList.remove("loading-state"); + if (!btn.originalWithIcon) { + btn.classList.remove("with-icon"); + } + btn.innerHTML = btn.originalContent; + } + + return btn; +} + +function cardStatusLoadingState(card, isLoading) { + if (card?.isLoading === isLoading) { + return false; + } + + const iconContainer = card.querySelector(".status-icon"); + if (!iconContainer) { + return false; + } + + if (isLoading) { + card.isLoading = true; + card.originalContent = iconContainer.innerHTML; + card.classList.add("loading-state"); + card.setAttribute("disabled", "disabled"); + iconContainer.innerHTML = ``; + } else { + card.isLoading = false; + if (!card.originalContent) { + return false; + } + card.removeAttribute("disabled"); + card.classList.remove("loading-state"); + iconContainer.innerHTML = card.originalContent; + } + + return card; +} + +function mainTemplate(sidebar, content) { + content = content ?? ""; + return ` +
    +
    + +
    + ${content} +
    +
    +
    + `; +} + +function connectionStatusBadge(status) { + return status === "active" + ? `Online` + : `Offline`; +} + +function toogleStateBadge(state) { + return state === "enabled" + ? `Enabled` + : `Disabled`; +} + +function timeAgo(dateString) { + const date = new Date(dateString.replace(" ", "T")); + const now = new Date(); + + const diffSeconds = Math.floor((now - date) / 1000); + if (diffSeconds < 60) { + return "less than a minute ago"; + } + + const minutes = Math.floor(diffSeconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (minutes < 60) { + return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`; + } + + if (hours < 24) { + const remainMinutes = minutes % 60; + return `${hours} hour${hours !== 1 ? "s" : ""} ${remainMinutes} minute${remainMinutes !== 1 ? "s" : ""} ago`; + } + + return `${days} day${days !== 1 ? "s" : ""} ago`; +} + +function formatDate(dateString) { + const date = new Date(dateString.replace(" ", "T")); + const now = new Date(); + + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const targetDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + + const diffDays = Math.floor((today - targetDay) / 86400000); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + + if (diffDays === 0) { + return `Today at ${hours}:${minutes}`; + } + if (diffDays === 1) { + return `Yesterday at ${hours}:${minutes}`; + } + + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()} at ${hours}:${minutes}`; +} + +function circleLoaderHTML() { + return ` +
    + + Loading +
    + `; +} + +function emptyHereHTML() { + return ` +
    +
    +

    It's empty here yet

    +
    + `; +} + +export default { + template: { + sidebarNav, + table, + createElement, + createAlert, + mainTemplate, + connectionStatusBadge, + toogleStateBadge, + circleLoaderHTML, + emptyHereHTML + }, + unification: { + fieldsUnification, + timeAgo, + formatDate + }, + states: { + btnLoadingState, + cardStatusLoadingState + } +}; diff --git a/src/js/components/toasts.js b/src/js/components/toasts.js index da8e671..97bea00 100644 --- a/src/js/components/toasts.js +++ b/src/js/components/toasts.js @@ -3,6 +3,10 @@ * * Mirrors the public surface of gnexus-ui-kit/src/js/components/toasts.js: * create, createInfo, createSuccess, createWarning, createError, createDanger + * + * create* return the initialized DOM element synchronously, matching the + * reference contract. Icons are loaded asynchronously and patched into the + * toast when available. */ const ICON_BY_TYPE = { @@ -26,7 +30,7 @@ function loadIconIndex() { if (indexPromise) return indexPromise; indexPromise = fetch("/assets/icons/index.json") - .then(r => r.json()) + .then(r => r.ok ? r.json() : {}) .catch(err => { console.error(err); return {}; @@ -68,7 +72,15 @@ } } -function template(type, iconSvg, title, text) { +function setIcon(placeholder, name) { + fetchIconSvg(name).then(svg => { + if (!svg || !placeholder.isConnected) return; + placeholder.innerHTML = ""; + appendIcon(placeholder, svg); + }); +} + +function template(type, iconName, title, text) { const toast = document.createElement("div"); toast.className = `toast toast-${type}`; toast.setAttribute("role", "alert"); @@ -78,9 +90,12 @@ const header = document.createElement("div"); header.className = "toast-header"; - appendIcon(header, iconSvg); - header.append(document.createTextNode(` ${title ?? ""}`)); + const iconPlaceholder = document.createElement("span"); + iconPlaceholder.className = "toast-icon"; + + header.append(iconPlaceholder); + header.append(document.createTextNode(` ${title ?? ""}`)); content.append(header); if (text) { @@ -101,13 +116,12 @@ close.className = "btn-icon toast-close"; close.type = "button"; close.setAttribute("aria-label", "Close"); - - fetchIconSvg("essentials/close-cross").then(svg => { - appendIcon(close, svg); - }); + close.innerHTML = "✕"; toast.append(content, close, progress); + setIcon(iconPlaceholder, iconName); + return toast; } @@ -162,12 +176,12 @@ return toast; } -async function create(type, iconOverride, title, text, props) { +function create(type, iconOverride, title, text, props) { const resolvedType = type === "error" ? "danger" : type; const iconName = iconOverride || ICON_BY_TYPE[resolvedType] || ICON_BY_TYPE.info; - const iconSvg = await fetchIconSvg(iconName); - const toast = template(resolvedType, iconSvg, title, text); - return init(toast, props); + const toastTitle = title !== undefined ? title : (TITLE_BY_TYPE[resolvedType] || ""); + const toast = template(resolvedType, iconName, toastTitle, text); + return init(toast, applyDefaults(props)); } function applyDefaults(props) { @@ -183,23 +197,19 @@ return props; } -async function createSuccess(title, text, props) { - props = applyDefaults(props); +function createSuccess(title, text, props) { return create("success", null, title, text, props); } -async function createInfo(title, text, props) { - props = applyDefaults(props); +function createInfo(title, text, props) { return create("info", null, title, text, props); } -async function createWarning(title, text, props) { - props = applyDefaults(props); +function createWarning(title, text, props) { return create("warning", null, title, text, props); } -async function createError(title, text, props) { - props = applyDefaults(props); +function createError(title, text, props) { return create("danger", null, title, text, props); } diff --git a/src/js/index.js b/src/js/index.js index 9a8ea90..cd6ed2b 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -3,9 +3,11 @@ * Mirrors the public API surface of gnexus-ui-kit for compatibility. */ +import Helper from "./components/helper.js"; import * as Icons from "./components/icon-helper.js"; import Toasts from "./components/toasts.js"; import Modals from "./components/modals.js"; +import Drawer from "./components/drawer.js"; import confirmPopup from "./components/confirm-popup.js"; import InputPatterns from "./components/input-patterns.js"; import editableString from "./components/editable-string.js"; @@ -26,9 +28,11 @@ import Loader from "./components/loader.js"; const api = { - Helper: Icons, + Helper, + Icons, Toasts, Modals, + Drawer, confirmPopup, InputPatterns, editableString, @@ -52,6 +56,7 @@ if (typeof window !== "undefined") { window.GNexusUIKit = api; window.VMKUIKit = api; + Object.assign(window, api); document.addEventListener("DOMContentLoaded", () => { Background.init?.(); @@ -68,5 +73,5 @@ }); } -export { Icons as Helper, Toasts, Modals, confirmPopup, InputPatterns, editableString, Overlays, advancedSelect, Tabs, Progress, Tables, Lists, Skeleton, Tooltips, Dropdowns, NavigationShell, Background, Breadcrumbs, Accordion, Popover, Loader }; +export { Helper, Icons, Toasts, Modals, Drawer, confirmPopup, InputPatterns, editableString, Overlays, advancedSelect, Tabs, Progress, Tables, Lists, Skeleton, Tooltips, Dropdowns, NavigationShell, Background, Breadcrumbs, Accordion, Popover, Loader }; export default api; diff --git a/src/vue/composables/useVueRouter.js b/src/vue/composables/useVueRouter.js new file mode 100644 index 0000000..0291f6c --- /dev/null +++ b/src/vue/composables/useVueRouter.js @@ -0,0 +1,54 @@ +/** + * Safe vue-router access without a hard dependency. + * Mirrors gnexus-ui-kit/src/vue/composables/useVueRouter.js. + */ + +import { getCurrentInstance } from "vue"; + +export function tryUseRouter() { + const instance = getCurrentInstance(); + return instance?.proxy?.$router || null; +} + +export function tryUseRoute() { + const instance = getCurrentInstance(); + return instance?.proxy?.$route || null; +} + +/** + * Check whether a route target matches the current route. + * + * @param {import("vue").Ref|Object} currentRoute + * @param {string|Object} to + * @param {string} strategy - 'exact' | 'prefix' + * @returns {boolean} + */ +export function isRouteActive(currentRoute, to, strategy = "prefix") { + if (!currentRoute) { + return false; + } + + const route = currentRoute.value || currentRoute; + + if (typeof to === "string") { + if (strategy === "exact") { + return route.path === to; + } + return route.path === to || route.path.startsWith(to + "/"); + } + + if (to.path) { + if (strategy === "exact") { + return route.path === to.path; + } + return route.path === to.path || route.path.startsWith(to.path + "/"); + } + + if (to.name) { + return route.name === to.name; + } + + return false; +} + +export default { tryUseRouter, tryUseRoute, isRouteActive }; diff --git a/src/vue/index.js b/src/vue/index.js index 3c826fa..2e8a52b 100644 --- a/src/vue/index.js +++ b/src/vue/index.js @@ -53,4 +53,5 @@ export { default as GnTimeline } from "./components/GnTimeline.js"; export { default as GnLoader } from "./components/GnLoader.js"; export { useToast } from "./composables/useToast.js"; -export { components, default as VMKUiVue } from "./plugin.js"; +export { tryUseRouter, tryUseRoute, isRouteActive } from "./composables/useVueRouter.js"; +export { components, VMKUiVue, GnexusUiVue } from "./plugin.js"; diff --git a/src/vue/plugin.js b/src/vue/plugin.js index 068bb85..3310246 100644 --- a/src/vue/plugin.js +++ b/src/vue/plugin.js @@ -105,10 +105,14 @@ GnLoader }; -export default { +const plugin = { install(app) { Object.entries(components).forEach(([name, component]) => { app.component(name, component); }); } }; + +export const VMKUiVue = plugin; +export const GnexusUiVue = plugin; +export default plugin; diff --git a/tests/functional.spec.js b/tests/functional.spec.js index 9cb14e9..6d42c0b 100644 --- a/tests/functional.spec.js +++ b/tests/functional.spec.js @@ -80,20 +80,20 @@ test("modal opens and closes", async ({ page }) => { const trigger = page.getByRole("button", { name: "Open modal" }); - const modal = page.locator("#demo-modal"); await trigger.click(); + const modal = page.locator(".modal").last(); await expect(modal).toHaveClass(/a-show/); await modal.locator(".modal-close").click(); - await expect(modal).not.toHaveClass(/a-show/); + await expect(modal).not.toBeVisible(); }); test("drawer opens and closes", async ({ page }) => { const trigger = page.getByRole("button", { name: "Open drawer" }); - const drawer = page.locator("#demo-drawer"); await trigger.click(); + const drawer = page.locator(".drawer").last(); await expect(drawer).toHaveClass(/a-show/); await drawer.locator(".drawer-backdrop").click(); - await expect(drawer).not.toHaveClass(/a-show/); + await expect(drawer).not.toBeVisible(); }); test("toast appears and can be closed", async ({ page }) => {