From 91bec72e896563c2a0cad4c2c16285b62549cbb7 Mon Sep 17 00:00:00 2001 From: "Michael E. Karpeles" Date: Sun, 17 May 2026 13:44:16 -0600 Subject: [PATCH 01/18] feat(header): replace native search facet with OlFacetSelect + OlSearchBar LIT components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swaps the legacy jQuery-driven header search bar for two new Lit web components while keeping full backward compatibility and an easy one-line rollback path. New components - OlFacetSelect (ol-facet-select): single-select popover built on OlPopover; fires ol-facet-select-change; keyboard ArrowUp/Down/Home/End - OlSearchBar (ol-search-bar): light-DOM wrapper that renders the full .search-bar-component block so existing jQuery selectors and global CSS continue to work without modification SearchBar.js bridge - Detects and switches to event-driven path - Listens for ol-facet-change instead of native path in SearchBar.js activates automatically. Playwright E2E tests (playwright.config.mjs + tests/e2e/header-search.spec.mjs): - Desktop: baseline, facet popover open/select, search submit, autocomplete - Mobile: collapsed baseline, tap-to-expand All 8 tests passing; 388 unit tests passing. [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- openlibrary/components/lit/OlFacetSelect.js | 280 ++++++++++++++++++ openlibrary/components/lit/OlSearchBar.js | 224 ++++++++++++++ openlibrary/components/lit/index.js | 2 + openlibrary/i18n/messages.pot | 28 +- .../plugins/openlibrary/js/SearchBar.js | 106 +++++-- openlibrary/templates/lib/nav_head.html | 37 +-- package-lock.json | 64 ++++ package.json | 2 + playwright.config.mjs | 28 ++ static/css/components/header-bar.css | 27 ++ tests/e2e/header-search.spec.mjs | 133 +++++++++ 11 files changed, 852 insertions(+), 79 deletions(-) create mode 100644 openlibrary/components/lit/OlFacetSelect.js create mode 100644 openlibrary/components/lit/OlSearchBar.js create mode 100644 playwright.config.mjs create mode 100644 tests/e2e/header-search.spec.mjs diff --git a/openlibrary/components/lit/OlFacetSelect.js b/openlibrary/components/lit/OlFacetSelect.js new file mode 100644 index 00000000000..95eae135993 --- /dev/null +++ b/openlibrary/components/lit/OlFacetSelect.js @@ -0,0 +1,280 @@ +import { LitElement, html, css } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { repeat } from 'lit/directives/repeat.js'; +import './OlPopover.js'; + +let _idCounter = 0; + +/** + * A single-select trigger button paired with a popover list of options. + * Selecting any option fires `ol-facet-select-change` and closes the popover. + * + * Composes `` for animation, focus trap, mobile tray, and + * Escape/outside-click dismissal. + * + * Intentionally minimal — no multi-select, no filter input, no SELECTED/ + * SUGGESTIONS grouping. Designed as a sibling primitive to `ol-select-popover`. + * + * @element ol-facet-select + * + * @prop {Array} options - List of `{ value, label }` objects. + * @prop {String} value - Currently selected value. + * @prop {String} accessibleLabel - Accessible label forwarded to the popover dialog. + * + * @fires ol-facet-select-change - Fired when the user picks an option. + * detail: { value: String, label: String } + * + * @example + * + */ +export class OlFacetSelect extends LitElement { + static properties = { + options: { type: Array }, + value: { type: String }, + accessibleLabel: { type: String, attribute: 'accessible-label' }, + _isOpen: { state: true }, + }; + + static styles = css` + :host { + display: inline-flex; + align-items: stretch; + font-family: var(--font-family-body); + } + + /* ── Trigger button ─────────────────────────────────────── */ + + .trigger { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 8px; + height: 100%; + min-height: 34px; + background: transparent; + border: none; + border-right: 1px solid var(--color-border-subtle, #ddd); + color: var(--darker-grey, #333); + font: inherit; + font-size: 14px; + font-weight: 500; + line-height: 1.4; + cursor: pointer; + white-space: nowrap; + transition: background 100ms ease; + } + + @media (hover: hover) and (pointer: fine) { + .trigger:hover { + background: var(--lightest-grey, #f5f5f5); + } + } + + .trigger:active { + transform: scale(0.97); + } + + .trigger:focus { + outline: none; + } + + .trigger:focus-visible { + outline: 2px solid var(--color-focus-ring, #0070b8); + outline-offset: -2px; + } + + .trigger-chevron { + display: inline-block; + width: 14px; + height: 14px; + flex-shrink: 0; + color: var(--accessible-grey, #666); + transition: transform 150ms ease-out; + } + + :host([data-open]) .trigger-chevron { + transform: rotate(180deg); + } + + @media (prefers-reduced-motion: reduce) { + .trigger-chevron { transition: none; } + } + + /* ── Panel ──────────────────────────────────────────────── */ + + .panel { + min-width: 130px; + } + + ul { + list-style: none; + margin: 0; + padding: 4px 0; + } + + li button { + display: block; + width: 100%; + padding: 8px 16px; + background: transparent; + border: none; + color: var(--darker-grey, #333); + font: inherit; + font-size: 14px; + text-align: left; + cursor: pointer; + } + + @media (hover: hover) and (pointer: fine) { + li button:hover { + background: var(--icon-link-grey, #f0f0f0); + } + } + + li button:focus { + outline: none; + } + + li button:focus-visible { + outline: 2px solid var(--color-focus-ring, #0070b8); + outline-offset: -2px; + } + + li[aria-selected="true"] button { + background: hsla(202, 96%, 37%, 0.08); + color: var(--link-blue, #0070b8); + font-weight: 600; + } + `; + + static _chevronIcon = html` + `; + + constructor() { + super(); + this.options = []; + this.value = ''; + this.accessibleLabel = ''; + this._isOpen = false; + this._panelId = `ol-facet-select-${++_idCounter}`; + } + + get _selectedLabel() { + const match = (this.options || []).find(o => o.value === this.value); + return match?.label ?? this.value; + } + + render() { + return html` + + + +
+
    + ${repeat(this.options || [], o => o.value, o => html` +
  • + +
  • + `)} +
+
+
+ `; + } + + // ── Event handlers ─────────────────────────────────────────── + + _onOpen() { + this._isOpen = true; + this.setAttribute('data-open', ''); + // Focus current selection (desktop only; skip on mobile to avoid keyboard popup) + if (!window.matchMedia('(max-width: 767px)').matches) { + requestAnimationFrame(() => { + const activeBtn = this.shadowRoot?.querySelector('li[aria-selected="true"] button'); + const firstBtn = this.shadowRoot?.querySelector('li button'); + (activeBtn || firstBtn)?.focus(); + }); + } + } + + _onClose() { + this._isOpen = false; + this.removeAttribute('data-open'); + } + + _onSelect(e) { + const value = e.currentTarget.dataset.value; + const opt = (this.options || []).find(o => o.value === value); + if (!opt) return; + + this.value = value; + this.dispatchEvent(new CustomEvent('ol-facet-select-change', { + bubbles: true, composed: true, + detail: { value: opt.value, label: opt.label }, + })); + + // Close the popover + const popover = this.shadowRoot?.querySelector('ol-popover'); + if (popover) popover.open = false; + } + + _onKeydown(e) { + const btns = Array.from(this.shadowRoot?.querySelectorAll('li button') || []); + if (!btns.length) return; + const active = this.shadowRoot?.activeElement; + const idx = btns.indexOf(active); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + btns[Math.min(idx + 1, btns.length - 1)]?.focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + btns[Math.max(idx - 1, 0)]?.focus(); + } else if (e.key === 'Home') { + e.preventDefault(); + btns[0]?.focus(); + } else if (e.key === 'End') { + e.preventDefault(); + btns[btns.length - 1]?.focus(); + } + } +} + +customElements.define('ol-facet-select', OlFacetSelect); diff --git a/openlibrary/components/lit/OlSearchBar.js b/openlibrary/components/lit/OlSearchBar.js new file mode 100644 index 00000000000..6943e51afb4 --- /dev/null +++ b/openlibrary/components/lit/OlSearchBar.js @@ -0,0 +1,224 @@ +import { LitElement, html } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; +import './OlFacetSelect.js'; + +/** + * Facet options for the header search bar. + * Labels are in English; i18n can be added via a `labels` property + * following the same pattern as OlPagination's label-* attributes. + */ +const FACET_OPTIONS = [ + { value: 'all', label: 'All' }, + { value: 'title', label: 'Title' }, + { value: 'author', label: 'Author' }, + { value: 'text', label: 'Text' }, + { value: 'subject', label: 'Subject' }, + { value: 'lists', label: 'Lists' }, + { value: 'advanced', label: 'Advanced' }, +]; + +const COLLAPSE_BREAKPOINT = 568; + +/** + * Mobile-optimized search bar for the Open Library header. + * + * Renders the full search bar structure (.search-bar-component) as a + * Light DOM component so that existing global CSS classes, jQuery event + * handlers, and the SearchBar.js autocomplete bridge all continue to work + * without modification. + * + * Replaces the native + + +
+ + + +
+
    +
    + + `; + } + + // ── Collapsible mode (mobile) ────────────────────────────────── + + _updateCollapsible() { + const shouldCollapse = window.innerWidth < COLLAPSE_BREAKPOINT; + if (shouldCollapse && !this._collapsible) { + this._collapsible = true; + this._collapsed = true; + this._applyExpandedClass(false); + } else if (!shouldCollapse && this._collapsible) { + this._collapsible = false; + this._collapsed = false; + this._applyExpandedClass(false); + this._showLogo(true); + } + } + + _applyExpandedClass(expanded) { + this.closest('.search-component')?.classList.toggle('expanded', expanded); + this._collapsed = !expanded; + } + + _showLogo(visible) { + document.querySelector('header#header-bar .logo-component') + ?.classList.toggle('hidden', !visible); + } + + expand() { + this._showLogo(false); + this._applyExpandedClass(true); + } + + collapse() { + this._showLogo(true); + this._applyExpandedClass(false); + } + + _onDocumentClick(e) { + if (!this._collapsible) return; + const searchComp = this.closest('.search-component'); + const target = e.target; + + const clickedInSearch = searchComp?.contains(target); + const clickedSearchLink = target.closest?.('a[href="/search"]'); + + if (clickedInSearch || clickedSearchLink) { + if (this._collapsed) { + e.preventDefault(); + this.expand(); + this.querySelector('input[name="q"]')?.focus(); + } + } else if (!this._collapsed) { + this.collapse(); + } + } + + // ── Facet change ─────────────────────────────────────────────── + + _onFacetChange(e) { + this.facet = e.detail.value; + this.dispatchEvent(new CustomEvent('ol-facet-change', { + bubbles: true, composed: true, + detail: { facet: e.detail.value, label: e.detail.label }, + })); + } +} + +customElements.define('ol-search-bar', OlSearchBar); diff --git a/openlibrary/components/lit/index.js b/openlibrary/components/lit/index.js index 90adfae20ab..0899912c467 100644 --- a/openlibrary/components/lit/index.js +++ b/openlibrary/components/lit/index.js @@ -11,6 +11,8 @@ export { OlPagination } from './OlPagination.js'; export { OLMarkdownEditor } from './OLMarkdownEditor.js'; export { OlPopover } from './OlPopover.js'; export { OlSelectPopover } from './OlSelectPopover.js'; +export { OlFacetSelect } from './OlFacetSelect.js'; +export { OlSearchBar } from './OlSearchBar.js'; export { OLChip } from './OLChip.js'; export { OLChipGroup } from './OLChipGroup.js'; export { OLButton } from './OLButton.js'; diff --git a/openlibrary/i18n/messages.pot b/openlibrary/i18n/messages.pot index c63482a64e5..4766d75e9bb 100644 --- a/openlibrary/i18n/messages.pot +++ b/openlibrary/i18n/messages.pot @@ -1113,13 +1113,13 @@ msgstr "" msgid "PR" msgstr "" -#: books/add.html books/edit.html books/edit/edition.html lib/nav_head.html +#: books/add.html books/edit.html books/edit/edition.html #: search/advancedsearch.html status.html type/about/edit.html #: type/page/edit.html type/template/edit.html msgid "Title" msgstr "" -#: books/add.html books/edit.html books/edit/edition.html lib/nav_head.html +#: books/add.html books/edit.html books/edit/edition.html #: openlibrary/plugins/worksearch/code.py search/advancedsearch.html #: search/work_search_selected_facets.html status.html msgid "Author" @@ -1458,7 +1458,7 @@ msgstr "" msgid "Role:" msgstr "" -#: about/index.html lib/nav_head.html type/tag/index.html +#: about/index.html type/tag/index.html msgid "All" msgstr "" @@ -3419,7 +3419,7 @@ msgstr "" msgid "Search for an Author" msgstr "" -#: authors/index.html lib/nav_head.html lists/home.html publishers/index.html +#: authors/index.html lists/home.html publishers/index.html #: publishers/notfound.html publishers/view.html search/advancedsearch.html #: search/publishers.html search/searchbox.html type/local_id/view.html msgid "Search" @@ -5660,22 +5660,6 @@ msgstr "" msgid "The Internet Archive's Open Library: One page for every book" msgstr "" -#: lib/nav_head.html -msgid "Text" -msgstr "" - -#: lib/nav_head.html search/advancedsearch.html -msgid "Subject" -msgstr "" - -#: lib/nav_head.html -msgid "Advanced" -msgstr "" - -#: lib/nav_head.html -msgid "Search by barcode" -msgstr "" - #: lib/not_logged.html msgid "" "You are not { + const { facet } = e.detail; + if (facet === 'advanced') { + this.navigateTo('/advancedsearch'); + return; + } + this.facet.write(facet); + }); + } else { + // Legacy path: native - - - - - - - - - - - - -
    -
      -
    -
    - + $if not ctx.user: diff --git a/package-lock.json b/package-lock.json index 1163a40e452..b1e67f6034d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@babel/preset-env": "^7.29.3", "@ericblade/quagga2": "^1.7.4", "@eslint/js": "^9.39.4", + "@playwright/test": "^1.60.0", "@tiptap/core": "^3.20.4", "@tiptap/extension-image": "^3.22.1", "@tiptap/extension-placeholder": "^3.20.4", @@ -3757,6 +3758,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", @@ -12284,6 +12301,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plur": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz", diff --git a/package.json b/package.json index 8cb3d177e14..a282d8ad2e2 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "serve": "node openlibrary/components/dev/serve-component.js && cd openlibrary/components/dev && vite", "test": "npm run test:js && bundlesize", "test:js": "jest", + "test:e2e": "playwright test", "storybook": "cd stories && npm install --no-audit && npx storybook dev -p 6006", "build-storybook": "cd stories && npm install --no-audit && npx storybook build" }, @@ -33,6 +34,7 @@ "@babel/preset-env": "^7.29.3", "@ericblade/quagga2": "^1.7.4", "@eslint/js": "^9.39.4", + "@playwright/test": "^1.60.0", "@tiptap/core": "^3.20.4", "@tiptap/extension-image": "^3.22.1", "@tiptap/extension-placeholder": "^3.20.4", diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 00000000000..eb9b3b2ed3c --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,28 @@ +// @ts-check +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright config for header search UX visual/E2E tests. + * Requires Docker to be running on port 8080: + * OL_MOUNT_DIR=$(pwd) docker compose up -d web + */ +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30000, + fullyParallel: false, + reporter: [['html', { outputFolder: 'tests/e2e/reports', open: 'never' }]], + use: { + baseURL: 'http://localhost:8080', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'desktop', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'mobile', + use: { ...devices['iPhone 12'] }, + }, + ], +}); diff --git a/static/css/components/header-bar.css b/static/css/components/header-bar.css index 7527b34f6c0..87137db872f 100644 --- a/static/css/components/header-bar.css +++ b/static/css/components/header-bar.css @@ -905,3 +905,30 @@ div.search-facet:focus-within { padding: var(--spacing-inset-sm) 0; } } + +/* ── ol-search-bar integration ────────────────────────────────────────────── */ + +/* LightDOM component is transparent to layout */ +ol-search-bar { + display: contents; +} + +/* Mirror legacy .search-facet behavior: hidden by default on mobile */ +.header-bar .search-component ol-facet-select { + display: none; + height: 100%; + align-self: stretch; +} + +/* Show when the search bar is expanded (mobile tap-to-expand) */ +/* stylelint-disable-next-line selector-max-specificity */ +.header-bar .search-component.expanded ol-facet-select { + display: inline-flex; +} + +/* Show on 568px+ — same breakpoint OlSearchBar uses for its collapse logic */ +@media only screen and (min-width: 35.5em) { + .header-bar .search-component ol-facet-select { + display: inline-flex; + } +} diff --git a/tests/e2e/header-search.spec.mjs b/tests/e2e/header-search.spec.mjs new file mode 100644 index 00000000000..89fbde133a8 --- /dev/null +++ b/tests/e2e/header-search.spec.mjs @@ -0,0 +1,133 @@ +// @ts-check +import { test, expect } from '@playwright/test'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCREENSHOTS = join(__dirname, 'screenshots'); + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function screenshotPath(name) { + return join(SCREENSHOTS, `${name}.png`); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +test.describe('Header Search — Desktop', () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test('baseline: page loads with header search visible', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('header#header-bar'); + + await page.screenshot({ path: screenshotPath('01-baseline-desktop'), clip: { x: 0, y: 0, width: 1280, height: 120 } }); + + // Search input is visible + const input = page.locator('header#header-bar input[name="q"]').first(); + await expect(input).toBeVisible(); + + // Facet selector area is visible (either native select or ol-facet-select) + const facetArea = page.locator('header#header-bar .search-bar').first(); + await expect(facetArea).toBeVisible(); + }); + + test('facet selector: shows current selection', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('header#header-bar'); + + const searchBar = page.locator('header#header-bar .search-bar').first(); + await expect(searchBar).toBeVisible(); + + await page.screenshot({ path: screenshotPath('02-facet-selector-default'), clip: { x: 0, y: 0, width: 500, height: 120 } }); + }); + + test('facet selector: ol-facet-select opens popover on click', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('ol-facet-select', { timeout: 5000 }).catch(() => { + test.skip(true, 'ol-facet-select not yet in page (pre-migration)'); + }); + + const trigger = page.locator('ol-facet-select .trigger').first(); + await trigger.click(); + + await page.screenshot({ path: screenshotPath('03-facet-popover-open'), clip: { x: 0, y: 0, width: 500, height: 400 } }); + + // Popover panel with options should be visible + const panel = page.locator('ol-popover').first(); + await expect(panel).toBeVisible(); + }); + + test('facet selector: selecting Title closes popover and updates trigger', async ({ page }) => { + await page.goto('/'); + const facetSelectEl = await page.locator('ol-facet-select').first().elementHandle(); + if (!facetSelectEl) { + test.skip(true, 'ol-facet-select not in page'); + return; + } + + const trigger = page.locator('ol-facet-select .trigger').first(); + await trigger.click(); + + // Click the "Title" option + const titleBtn = page.locator('ol-facet-select li button').filter({ hasText: 'Title' }).first(); + await titleBtn.click(); + + await page.screenshot({ path: screenshotPath('04-facet-title-selected'), clip: { x: 0, y: 0, width: 500, height: 120 } }); + + // Trigger should now show "Title" + await expect(trigger).toContainText('Title'); + }); + + test('search: submitting navigates to /search', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('header#header-bar input[name="q"]'); + + const input = page.locator('header#header-bar input[name="q"]').first(); + await input.fill('frankenstein'); + await input.press('Enter'); + + await page.waitForURL(/\/search\?.*q=frankenstein/, { timeout: 10000 }); + expect(page.url()).toContain('/search'); + expect(page.url()).toContain('frankenstein'); + }); + + test('autocomplete: shows results after 3+ characters', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('header#header-bar input[name="q"]'); + + const input = page.locator('header#header-bar input[name="q"]').first(); + await input.fill('dune'); + + // Wait for autocomplete results to appear (debounced at 500ms) + await page.waitForSelector('header#header-bar .search-results li', { timeout: 5000 }).catch(() => null); + await page.screenshot({ path: screenshotPath('05-autocomplete-results'), clip: { x: 0, y: 0, width: 600, height: 400 } }); + }); +}); + +test.describe('Header Search — Mobile', () => { + test.use({ viewport: { width: 375, height: 812 } }); + + test('baseline: mobile layout at 375px', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('header#header-bar'); + + await page.screenshot({ path: screenshotPath('06-baseline-mobile'), clip: { x: 0, y: 0, width: 375, height: 120 } }); + }); + + test('mobile: search expands on click', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('header#header-bar'); + + // Click the search area to expand it + const searchComponent = page.locator('header#header-bar .search-component').first(); + await searchComponent.click(); + + await page.waitForTimeout(300); + await page.screenshot({ path: screenshotPath('07-mobile-expanded'), clip: { x: 0, y: 0, width: 375, height: 160 } }); + }); +}); From b40d88fe6f056de087723a7e5c29ee94635c1dc5 Mon Sep 17 00:00:00 2001 From: "Michael E. Karpeles" Date: Sun, 17 May 2026 14:32:24 -0600 Subject: [PATCH 02/18] docs(ai): add front-end dev environment guide with session lessons --- docs/ai/README.md | 1 + docs/ai/front-end.md | 276 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 docs/ai/front-end.md diff --git a/docs/ai/README.md b/docs/ai/README.md index 922baacd9bd..de040ba5bd9 100644 --- a/docs/ai/README.md +++ b/docs/ai/README.md @@ -167,6 +167,7 @@ These companion docs cover specific areas in depth: - [CSS](css.md) — BEM naming, selector rules, tokens in practice, bundle sizes, CSS-to-template wiring - [Design](design.md) — UI design patterns: typography, layout shift prevention, design tokens, animations - [Web Component Standards](web-components.md) — When to build a component, Lit conventions, accessibility, events +- [Front-End Dev Environment](front-end.md) — Build commands, Lit/jQuery timing trap, Colima LAN access, Playwright setup, known flaky tests, pre-commit.ci squash procedure ## Key File Locations diff --git a/docs/ai/front-end.md b/docs/ai/front-end.md new file mode 100644 index 00000000000..dc9d36ae4d8 --- /dev/null +++ b/docs/ai/front-end.md @@ -0,0 +1,276 @@ +# Front-End Dev Environment Guide + +Lessons from building the `ol-search-bar` LIT component. These are the non-obvious +gotchas that will waste your day if you don't already know them. + +--- + +## Build Commands: Which to Run and When + +| What changed | Command | +|---|---| +| CSS / LESS only | `docker compose run --rm home make css` | +| JS only (`openlibrary/plugins/openlibrary/js/`) | `docker compose run --rm home make js` | +| Lit components (`openlibrary/components/lit/`) | `docker compose run --rm home make lit-components` | +| Templates only | `docker compose restart web` (no make needed) | +| Multiple or unsure | `docker compose run --rm home make all` | +| After any make | `docker compose restart web` to pick up new bundles | + +**Do not skip `docker compose restart web`** after building JS or Lit components. The +web container caches the old bundle path and will serve stale assets. + +After `make js` or `make lit-components`, always verify the build succeeded with zero +errors before restarting: + +```bash +# Confirm the Lit bundle was written +ls -lh static/build/lit-components/ol-components.js +``` + +--- + +## LIT Component + jQuery Timing: The Load-Order Problem + +**This is the single most dangerous trap when wiring a new Lit component into OL's jQuery code.** + +`all.js` is loaded as a plain `