---
name: web-ui-repair
description: "Fix common static web app UI issues: broken shared dependencies, readability, browser cachiness, and dev server setup."
version: 1.0.0
author: Hermes Agent
license: MIT
platforms: [linux, macos]
metadata:
  hermes:
    tags: [web, frontend, html, css, javascript, ui, debugging, repair]
    related_skills: [systematic-debugging, spike, codebase-audit]
---

# Web UI Repair

## Overview

Patterns for fixing common issues in static HTML/CSS/JS web apps — multi-page sites where some pages work and others don't, readability problems, and browser cache issues after fixes.

This skill covers **post-investigation repair actions**. For root cause investigation methodology, use `systematic-debugging`. For diagnostic patterns (JS-overrides-HTML, missing script imports, class-based dark mode search), see `references/frontend-debugging-patterns.md` (absorbed from `frontend-debugging`).

---

## 1. Readability & Contrast Fixes

### The Problem

Colored text on colored backgrounds (e.g., `text-yellow-600` on `bg-yellow-50`, or `text-red-500` on `bg-red-50`) makes content unreadable, especially in dark mode.

### The Fix

Use a **white/dark card with colored accent border** pattern for alert banners OR a **subtle colored bg with bold text** pattern for stat badges:

**Pattern A — Alert banners (white card + colored left-border accent):**

```
<div class="bg-white dark:bg-gray-800 border-l-4 border-red-500 rounded-lg shadow-sm px-4 py-3.5">
  <strong class="font-semibold text-gray-900 dark:text-white">Overdue Tasks:</strong>
  <span class="font-semibold text-red-600 dark:text-red-300">3</span>
  <span class="text-gray-700 dark:text-gray-300"> task(s) are overdue!</span>
</div>
```

**Pattern B — Stat badges (colored bg + bold colored text):**

```
<div class="bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-700 rounded-xl p-3 text-center">
  <div class="text-xl font-bold text-red-700 dark:text-red-300">0</div>
  <div class="text-xs font-semibold text-red-600 dark:text-red-400">Overdue</div>
</div>
```

**Key rules for readability:**

- NEVER use colored text on a same-colored background (e.g. `text-yellow-700` on `bg-yellow-50`)
- For the white-card + accent pattern: use `text-gray-900 dark:text-white` for headings, `text-gray-700 dark:text-gray-300` for body text
- For the colored-bg badge pattern: use the **darkest shade** of the color for text (`text-red-700`, `text-amber-700`) — NOT a washed-out tone
- Numbers/values should be bold and high-contrast
- **Avoid washed-out grays** for primary content — reserve `text-gray-500` and below for secondary/helper text only

**Color scheme mapping for badges/stat cards:**
| Intent | Border accent | Value color |
|--------|--------------|-------------|
| Overdue / Error | `border-red-200 dark:border-red-900/50` | `text-red-600 dark:text-red-400` |
| Due Today / Warning | `border-amber-200 dark:border-amber-900/50` | `text-amber-600 dark:text-amber-400` |
| Upcoming / Info | `border-blue-200 dark:border-blue-900/50` | `text-blue-600 dark:text-blue-400` |
| Success | `border-green-200 dark:border-green-900/50` | `text-green-600 dark:text-green-400` |

---

## 2. Browser Cache Busting

### The Problem

After editing HTML/CSS/JS files on a static server (Python http.server, Nginx, etc.), the user's browser may serve cached versions of the old files — they won't see your changes.

### The Fix

**Add cache-busting meta tags to the `<head>`:**

```html
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
```

**Also tell the user to hard refresh:**
- Desktop: `Ctrl+Shift+R` / `Cmd+Shift+R`
- Phone: close tab completely, open fresh

**Pitfalls:**
- `Cache-Control` meta tags don't always work on all browsers (especially mobile Safari/Chrome). When they don't, the meta tags still act as a signal to the user to try again.
- If meta tags aren't enough, restart the dev server after file changes (forces a fresh connection).
- For production sites, use file-versioned URLs (e.g., `style.css?v=2`) or content hashing in the filename.

**⚠️ Server-side cache pitfall — `Cache-Control: immutable`:**

Nginx configs often set aggressive caching on static assets:
```nginx
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
    expires 30d;
    add_header Cache-Control "public, immutable";
}
```

The `immutable` directive tells the browser **"this file will never change — don't even ask the server if there's a newer version."** After editing JS/CSS files, the browser serves the stale cached version forever. Meta tags in HTML don't override this.

**Fix:** Change to must-revalidate with a shorter expiry:
```nginx
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
    expires 1h;
    add_header Cache-Control "public, must-revalidate";
}
```

Then reload nginx: `sudo nginx -t && sudo systemctl reload nginx`

**Verification:**
```bash
curl -sI -k https://your-site.com/path/to/file.js | grep -i cache
# Should show: Cache-Control: public, must-revalidate  (NOT "immutable")
```

**⚠️ Before debugging JS logic, verify the served HTML matches what's on disk.**

When a fix was applied to a file but the user reports it didn't take effect, the FIRST diagnostic step is to confirm what nginx is actually serving — not what's on the filesystem:

```bash
# Compare what's on disk vs what's served
grep 'fix-pattern' /path/to/file.html    # Should match
curl -sk https://localhost/file.html | grep 'fix-pattern'  # Should ALSO match
```

If they differ, the problem is between disk and server — not in the JS logic. Common causes:
- **Nginx serving from a different root** than the file you're editing (check `root` directive in all `server` blocks, especially `sites-enabled/` vs `sites-available/`)
- **Multiple server blocks on different ports** — the `sites-enabled/` version may have different `listen` and `root` directives than the `sites-available/` version you inspected
- **Symlink vs copy** — `sites-enabled/` entries may be symlinks to `sites-available/` OR standalone files with different content
- **Browser cache** — curl bypasses browser cache; if curl shows the fix but the user doesn't see it, the issue is client-side caching

**Runtime cache-busting for redirects — `?cb=` pattern:**

When the user's browser already has a stale cached page (from before the nginx fix), even a hard refresh may not clear it. Add a cache-busting query parameter to ALL redirects so the browser treats each navigation as a fresh request:

```javascript
// Auth guard redirect (with cache-busting)
location.replace('login.html?cb=' + Date.now());

// Sign-out redirect
localStorage.removeItem('pocketbase_auth');
location.replace('login.html?cb=' + Date.now());

// Login success redirect
location.replace('index.html?cb=' + Date.now());
```

Use `location.replace()` (not `location.href`) to prevent back-button loops. The `?cb=` parameter is ignored by static servers but forces the browser to bypass its cache and fetch a fresh copy. Apply this to ALL redirects: sign-out buttons, auth guards, login form success handlers — every path that navigates between pages.

---

## 3. Dev Server Port Selection

### The Problem

Starting a static HTTP server on a port already in use causes it to fail silently or conflict with another service.

### The Fix

1. Check which ports are in use first:
   ```bash
   ss -tlnp 2>/dev/null || netstat -tlnp 2>/dev/null
   ```

2. Pick a port NOT in the list. Common safe picks that are rarely used by system services:
   - `5500`, `8081`, `8888`, `9999`, `3333`, `4444`, `5555`, `7777`

3. Verify the server started successfully:
   ```bash
   ss -tlnp | grep <port>
   ```

4. Common ports that ARE often in use and should be avoided:
   - `3000` (often used by dev tools like Selkies, React dev server)
   - `8000`, `8080` (common HTTP alternatives)
   - `8443`, `9090`, `9443` (dashboard tools)
   - `3001`, `8787`, `2283` (self-hosted apps)

**Pitfalls:**
- Some services (like Selkies) may use port 3000 without showing in `ss` output — avoid port 3000 by default.
- Python's `http.server` needs `SIGINT` (Ctrl+C) or `kill` to stop cleanly before restarting on a new port.

---

## 5. Compact Nav-Bar Header Redesign

### Context

Multi-page static web apps (Dashboard, Repair Orders, Customers, Appointments) often have a tall header with logo, title, subtitle, and a separate navigation bar below. Users may want a **compact, single-row header** that removes the logo/title section entirely.

### The Pattern

Instead of two sections (top content + nav bar), combine everything into one nav bar row:

```
┌──────────────────────────────────────────────────┐
│ [☰]  Dashboard  Repair Orders  Appts  Customers  │ [🌙] [👤] │
├──────────────────────────────────────────────────┤
│              Mobile Navigation (hidden)           │
└──────────────────────────────────────────────────┘
```

**Structure breakdown (left to right):**

| Element | Desktop | Mobile |
|---------|---------|--------|
| Hamburger button (`#mobile-menu-btn`) | Hidden (`lg:hidden`) | Visible |
| Desktop nav links | Visible (`hidden lg:flex`) | Hidden |
| **Mobile page title** | Hidden (`lg:hidden`) | **Visible center** |
| Dark mode toggle | Visible (sm+) | Hidden (in dropdown) |
| User menu button | Visible | Visible |

### Implementation Steps

1. **Remove the top content section** — logo icon, page title `<h1>`, subtitle `<p>` — everything inside the header-card's inner content div
2. **Merge controls into the nav bar** — move the mobile menu button, dark mode toggle, and user menu into the same flex row as the nav links
3. **Add mobile page title** — a `<span class="lg:hidden text-sm font-semibold text-white">Page Name</span>` between the left and right sections
4. **Tighten padding** — outer container: `p-2 md:p-4`, inner: `py-2 md:py-2.5`, nav link padding: `py-1.5 md:py-2`

### Key CSS Classes to Use

**Desktop nav links:**
- Active page: `bg-white/20 text-white shadow-sm`
- Inactive pages: `text-blue-100 hover:text-white hover:bg-white/10`

**Mobile nav links:**
- Active page: `bg-white/20 text-white shadow-sm`
- Inactive pages: `text-blue-100 hover:bg-white/10 hover:text-white`

**Mobile navigation panel:**
- Hidden by default: `lg:hidden hidden`
- Toggled by `#mobile-menu-btn` click handler in `shared/header-functionality.js`

### Pitfalls

- **Active page must be correct.** Each page's nav has the active page link styled with `bg-white/20 text-white shadow-sm`. Triple-check: if you copy-paste the header from dashboard.html to repair-orders.html, the active link (Dashboard with bg-white/20) will show incorrectly on the repair-orders page.
- **⚠️ Active nav-link may be too subtle.** On a blue gradient header, `bg-white/20` (20% white) vs the parent's `bg-white/10` may not be enough contrast. The difference is particularly hard to see on mobile. Fix: add a dedicated CSS class for the active page with higher opacity (35%) and a subtle shadow:
  ```css
  .nav-link.active-page {
      background: rgba(255,255,255,0.35) !important;
      box-shadow: 0 2px 8px rgba(0,0,0,0.15) !important;
  }
  .dark .nav-link.active-page {
      background: rgba(255,255,255,0.25) !important;
  }
  ```
  Add the `active-page` class to the current page's nav link (`<a class="nav-link active-page ...">`). Apply to ALL pages' active nav links for consistency. This makes the active pill visibly brighter than the inactive `text-blue-100` links.
- **⚠️ Missing `.nav-link` CSS on individual pages.** The `nav-link` class may have associated CSS rules (`position: relative; overflow: hidden;` plus a `::before` hover shine animation) defined inline in some pages' `<style>` blocks but not others. After redesigning the header, check every page for these rules:

  ```bash
  grep -c '\.nav-link\s*{' *.html
  # Pages that show 0 are missing the CSS. Copy from a page that has it.
  ```

  Without these rules, nav links lack the hover shine animation and the proper positioning context for stacking. The rules should be identical across all pages.
- **Mobile page title must match the page.** Hardcode it per page — don't try to make it dynamic.
- **User menu dropdown z-index.** It should be `z-[100]` or higher to stack above the nav bar.
- **Outer wrapper consistency.** Some pages use a gradient background (`bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800`) while others use a solid white/dark background (`bg-white dark:bg-gray-900`). Preserve whatever wrapper the original page used.
- **⚠️ Dropdown clipping after header redesign.** After removing the tall header section, the user menu dropdown may appear behind page content. This happens because the outer header wrapper has no `position` or `z-index`, so the sibling content div (which comes later in DOM order) paints on top. **Always add** `style="position: relative; z-index: 1000;"` to the outer header wrapper div as part of the redesign. See section 7 for the full diagnosis and fix checklist.
- **⚠️ Remove `overflow-hidden` from header-card.** The header card may have Tailwind's `overflow-hidden` class alongside a compensating `overflow-visible-important` custom class. Remove `overflow-hidden` entirely to eliminate the conflict. See section 7 Fix A.
- **⚠️ `--z-critical` may be undefined.** Some pages reference `z-index: var(--z-critical)` in style.css without ever defining `--z-critical` in `:root`. Add `:root { --z-critical: 999999; }` to the site's CSS file.

### When to Apply

- User says "the header is too big", "make the header smaller", "remove the top part"
- User wants consistent headers across pages
- The original header has a separate logo/title section and navigation bar

### Centering Nav Links On Desktop

After making the header compact, the user may want the nav buttons centered on the banner (desktop only).

**Before (left-aligned):**
```html
<div class="flex items-center justify-between py-2 md:py-2.5">
  <div class="flex items-center gap-0.5">
    <button id="mobile-menu-btn" class="lg:hidden p-2...">☰</button>
    <div class="hidden lg:flex items-center gap-0.5">
      <a href="..." ...>Dashboard</a> ...
    </div>
  </div>
  <span class="lg:hidden ...">Page</span>
  <div class="flex items-center gap-1.5">... user menu ...</div>
</div>
```

**After (centered on desktop):**
```html
<div class="flex items-center py-2 md:py-2.5">
  <div class="flex-1 flex lg:hidden">
    <button id="mobile-menu-btn" class="p-2...">☰</button>
  </div>
  <div class="hidden lg:flex items-center gap-0.5 mx-auto">
    <a href="..." ...>Dashboard</a> ...
  </div>
  <span class="lg:hidden ...">Page</span>
  <div class="flex-1 flex justify-end items-center gap-1.5">
    ... user menu ...
  </div>
</div>
```

Key: `flex-1` spacers on both sides + `mx-auto` on nav links = perfect centering.

### Workflow

When making the same structural change across multiple pages:

1. **Do ONE page first** — typically the dashboard/index page. Get the user to check it on both desktop and mobile.
2. **Iterate** — the user will almost certainly want tweaks (mobile page title, smaller padding, etc.)
3. **Get approval** — confirm they like it before applying to the other pages
4. **Replicate** — apply the exact same diff pattern to the remaining pages, adjusting only:
   - The active nav link (different per page)
   - The mobile page title text
   - The outer wrapper class (some pages use gradient, some use solid)
5. **Verify** — quickly check that each page's active link is correct and the header renders without layout breaks

This avoids doing the same work 4 times when the user changes their mind after seeing the first implementation.

**Multi-page header restyling (variant selection + replication):** When the user picks a design variant from a comparison page and asks you to apply it to all pages, see `references/multi-page-header-redesign.md` for the step-by-step replication workflow (CSS → HTML → per-page active links → verify).

## 7. Dropdown Clipping / Z-Index FixesThis avoids doing the same work 4 times when the user changes their mind after seeing the first implementation.

**Multi-page header restyling (variant selection + replication):** When the user picks a design variant from a comparison page and asks you to apply it to all pages, see `references/multi-page-header-redesign.md` for the step-by-step replication workflow (CSS → HTML → per-page active links → verify).

## 7. Dropdown Clipping / Z-Index Fixes

### The Problem

An absolute-positioned dropdown (e.g. the user account menu) is clipped by its parent card or appears behind sibling content below it. Two distinct root causes:

**Cause A — Parent overflow clips:** A parent has `overflow: hidden` (from Tailwind's `overflow-hidden` class) that clips the dropdown's visible area.

**Cause B — Wrong stacking context:** The dropdown has a high `z-index` but its parent wrapper is a sibling of the main content div in the DOM. Since the main content comes AFTER the header in DOM order, it paints on top — z-index on the child doesn't help because the stacking context is at the parent level.

### The Fix

**Fix A — Remove overflow clipping:**
```css
/* Remove overflow-hidden from the parent card */
.header-card {
    overflow: visible !important;
}

/* Ensure the dropdown container doesn't clip */
.user-menu-container {
    position: relative !important;
    overflow: visible !important;
}
```

In the HTML, replace `overflow-hidden` with `overflow-visible-important` class or inline `style="overflow: visible !important;"` on the header card:
```html
<!-- Before (clips dropdown) -->
<div class="header-card ... overflow-hidden relative overflow-visible-important">

<!-- After (allows dropdown) -->
<div class="header-card ... relative overflow-visible-important">
```

**Fix B — Give the outer wrapper its own stacking context:**
```html
<!-- The OUTER header wrapper needs position + z-index, not just the header-card -->
<div class="bg-white dark:bg-gray-900 border-b ..." style="position: relative; z-index: 1000;">
```

This creates a stacking context at the body level, so the entire header (including its children's dropdowns) paints above sibling content.

**Fix C — Define the CSS variable:**
Some skills use `z-index: var(--z-critical)` without defining the variable:
```css
:root {
    --z-critical: 999999;
}
```

### Diagnosis

To tell which cause it is, inspect in DevTools:

1. **Overflow clipping:** Check the header card for `overflow: hidden` in the Computed styles tab
2. **Stacking context:** Check if the outer header wrapper has `position` set. If not, any `z-index` on children is limited to that wrapper's default stacking order
3. **Undefined variable:** Search for `--z-` in CSS; if `var(--z-critical)` is used but never defined in `:root`, that's the problem

### Complete Fix Checklist

When a dropdown is clipped or behind content:

| Step | What | Why |
|------|------|-----|
| 1 | Remove `overflow-hidden` from header-card | Prevents clipping |
| 2 | Add `position: relative; z-index: 1000` to the outer header wrapper | Creates body-level stacking context |
| 3 | Define `:root { --z-critical: 999999; }` in CSS | Enables dropdown z-index if it uses this variable |
| 4 | Ensure `.user-menu-container { overflow: visible !important; }` | Prevents the immediate parent from clipping |

---

## 8. Permanent Dark Mode Enforcement

### The Problem

The user wants the site to ALWAYS display dark mode colors — no light mode at all. They want to remove the dark mode toggle entirely.

This is NOT the same as setting a default. It means making it IMPOSSIBLE for the site to ever enter light mode.

### The Challenge

A typical Tailwind `class`-based dark mode site has **multiple JS toggle points** that can remove the `.dark` class. In this session's 4-page web app, there were **9 toggle points across 5 JS files**:

| File | Line | Code |
|------|------|------|
| `ui.js` | Toggle handler | `classList.toggle('dark', e.target.checked)` |
| `ui.js` | Initialization (else branch) | `classList.remove('dark')` |
| `shared/header-functionality.js` | Dark mode listener (×2) | `classList.toggle('dark', e.target.checked)` |
| `dashboard.js` | Settings sync | `classList.toggle('dark', dark)` |
| `customers.js` | Settings sync | `classList.toggle('dark', val)` |
| `settings.js` | `applyDarkModeLocally` | `classList.toggle('dark', isDark)` |
| `settings.js` | Site dark mode change handler | `classList.toggle('dark', checked)` |
| `settings.js` | Remote settings sync | `classList.toggle('dark', dark)` |

### The Fix (Three Layers)

**Layer 1 — HTML: Set the class on the root element.**
```html
<html lang="en" class="dark">
```

**Layer 2 — JS: Replace EVERY `classList.toggle('dark', ...)` with `classList.add('dark')`.**
Also replace any `classList.remove('dark')` with `classList.add('dark')`.
```javascript
// BEFORE (any of these forms):
document.documentElement.classList.toggle('dark', e.target.checked);
document.documentElement.classList.toggle('dark', dark);
document.documentElement.classList.remove('dark');

// AFTER (every single one):
document.documentElement.classList.add('dark');
```

To find all toggle points, search across ALL JS files:
```bash
grep -rn "classList.*toggle.*dark\|classList.*remove.*dark" /path/to/js/files/
```

**Layer 3 — HTML: Remove the toggle UI elements.**
- The header dark mode toggle: a `<div class="hidden sm:flex ...">` containing the "Dark Mode" label + toggle-switch
- The mobile dark mode toggle: a `<div class="sm:hidden border-t ...">` section inside the user dropdown menu with `id="mobile-dark-mode-toggle"`
- Any site settings dark mode toggle in modals (search `Dark Mode` text across all HTML files)

Search anchor: `grep -rn "Dark Mode" *.html` to find all instances across pages. Each page typically has two to remove (header toggle + mobile dropdown toggle), plus potentially one in settings modals.

⚠️ The HTML structure can differ between pages (some have a `<!-- Dark Mode Toggle -->` comment before the div, others don't). Check the exact surrounding HTML before crafting patch strings. Use `read_file` to verify each page's structure.

### Verification

1. Hard refresh the page — it should load dark
2. Click every toggle/button that previously controlled dark mode — nothing should change
3. Open the settings modal — any dark mode toggle there should be gone or inoperable
4. Check localStorage — `darkMode` should always be `'true'`, `theme` should always be `'dark'`

### ⚠️ Pitfall: Missing one JS toggle point

If even ONE `classList.toggle('dark', ...)` remains, the user can trigger it through some UI path (settings modal, site config, keyboard shortcut) and the site will switch to light mode. This is the most common failure — always search all `.js` files, not just the obvious UI files. Settings sync code in `dashboard.js`, `customers.js`, and `settings.js` are the hidden ones.

### When to Apply

- User says "I want the default colors to be as if it was on dark mode" (first attempt → set `class="dark"` default)
- User then says "the backgrounds are turning white" (after first attempt — JS is removing the class)
- User says "I don't like the not dark mode at all" (remove toggle entirely)
- User says "remove the dark mode toggle completely"

---

## 9. Card Background Styling (Light Mode)

### The Problem

Cards with `bg-white` (plain white background) look flat and visually boring in light mode. When the user toggles dark mode off, the lack of visual depth is especially noticeable.

### The Fix

Use a **solid light-gray** or **subtle gradient** instead of plain white, plus a soft border.

**Approach A — Solid light-gray (most visible, recommended when user says "still white"):**
```css
.card-class {
    background: #f8fafc;
    border: 1px solid #e2e8f0;
    border-radius: 1rem;
    transition: all 0.3s ease;
}
```

**Approach B — Subtle gradient (adds depth, but may still look white):**
```css
.card-class {
    background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
    border: 1px solid rgba(226, 232, 240, 0.9);
    border-radius: 1rem;
    transition: all 0.3s ease;
}
```

**Common hover/dark (both approaches):**
```css
.card-class:hover {
    transform: translateY(-2px);
    box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
    border-color: rgba(148, 163, 184, 0.6);
}
.dark .card-class {
    background: #1e293b;
    border: 1px solid rgba(71, 85, 105, 0.4);
}
```

**Key values:**
- Light solid: `#f8fafc` (slate-50) + border `#e2e8f0` (slate-200)
- Light gradient: `#ffffff → #f8fafc` — may still be perceived as "white" by users
- Dark: `#1e293b` (slate-800) + border `rgba(71, 85, 105, 0.4)`
- Hover lift: `translateY(-2px)` + enhanced shadow

⚠️ **Pitfall — subtle gradients can read as "still white."** In this session, `#ffffff → #f0f4f8` was rejected. Switch to solid `#f8fafc` with visible `#e2e8f0` border. The border is what makes the card distinguishable from the white page background.

⚠️ **Pitfall — duplicate CSS selectors override each other.** If a `<style>` block has multiple `.dashboard-card` definitions (same selector, same specificity), the LAST one wins. This caused confusion where a gradient background was defined but overridden by a later definition with a different background. When cards don't look right, search for duplicate class selectors in the CSS — particularly in pages that accumulated styles over multiple edit sessions.

### When to Apply

- Page has cards/grids with `bg-white` or `bg-white dark:bg-gray-800` that look flat
- User complains about "plain white" aesthetic in light mode
- Cards on different pages use different background styles and need to be unified

**WHEN a UI element (menu, dropdown, modal, button) works on pages A & B but not on page C:**

Compare page C against a working page across three layers:

| Layer | What to check | How |
|-------|--------------|-----|
| **Scripts** | `<script>` tags at bottom of page | Is the shared JS file loaded? Compare both pages' script blocks. |
| **HTML** | The element's ID/class | Does the element exist on page C? Search for `id="<target>"` |
| **CSS** | Style rules for the element | Check inline `<style>` and external CSS for relevant rules |

Typical fix: add the missing script tag, add the missing HTML element, add the missing CSS — or all three.

See `systematic-debugging` Phase 2 step 4 for the full investigation methodology.

---\n\n## 10. Inline onclick Bypass for Broken JS Module Handlers\n\n### The Problem\n\nA button or interactive element has no response when clicked. The JS module that should attach its event handler (`header-functionality.js`, `dropdown-manager.js`, etc.) fails to load — due to cached stale files, import chain errors, or async race conditions. The user can't interact with critical UI (dropdowns, logout, modals).\n\n### The Fix\n\nAdd an inline `onclick` attribute directly to the HTML element. This bypasses ALL module loading — no imports, no async, no cache. It just works.\n\n**Pattern — dropdown toggle:**\n```html\n<button id=\"my-dropdown-btn\"\n  onclick=\"(function(e){e.stopPropagation();const d=document.getElementById('my-dropdown');if(!d)return;const h=d.classList.toggle('hidden');d.style.display=h?'none':'block';d.style.visibility=h?'hidden':'visible';d.style.opacity=h?'0':'1';d.style.zIndex='999999';const b=document.getElementById('my-dropdown-btn');b.setAttribute('aria-expanded',!h);})(event)\">\n  Menu\n</button>\n```\n\n**Pattern — click-outside-to-close (inline script at bottom of `<body>`):**\n```html\n<script>\ndocument.addEventListener('click', function(e) {\n  const btn = document.getElementById('my-dropdown-btn');\n  const dd = document.getElementById('my-dropdown');\n  if (dd && !dd.classList.contains('hidden') && btn && !btn.contains(e.target) && !dd.contains(e.target)) {\n    dd.classList.add('hidden'); dd.style.display = 'none';\n    btn.setAttribute('aria-expanded', 'false');\n  }\n});\n</script>\n```\n\n### When to Use\n\n- Button click does nothing (no dropdown, no modal, no redirect)\n- Console shows no errors but the handler never fires\n- JS module imports are known to be unreliable (caching, CORS, dynamic import failures)\n- As a diagnostic step: if inline onclick works but the JS handler doesn't, the module loading is the problem\n\n### Pitfalls\n\n- **Not a permanent fix** — this is a diagnostic tool and fail-safe. The proper fix is to resolve the module loading issue (cache headers, import paths, missing dependencies). **If multiple buttons across multiple pages are broken, stop patching individual buttons and check for a missing shared dependency — see Section 15.**\n- **Don't remove the JS handler** — leave both. The inline version is the fallback\n- **IIFE wrapper required for simple cases** — use `(function(e){...})(event)` to avoid polluting global scope and to get the event object reliably. For complex inline handlers with nested quotes, the IIFE can become fragile — use the **global function pattern** instead (see below)
- **Global function pattern (cleaner than IIFE):** When an inline handler is complex or shared across multiple pages, define a named function in a script block and call it from `onclick`:
  ```html
  <!-- Button: simple onclick call -->
  <button onclick="toggleAccountDropdown(event)">Account</button>
  
  <!-- Script block at bottom of body -->
  <script>
  function toggleAccountDropdown(e){
      e.stopPropagation();
      var d=document.getElementById('user-dropdown');
      if(!d)return;
      var hidden=d.classList.toggle('hidden');
      d.style.display=hidden?'none':'block';
      d.style.zIndex='999999';
      var b=document.getElementById('user-menu-btn');
      if(b)b.setAttribute('aria-expanded',!hidden);
  }
  </script>
  ```
  This is easier to read, debug, and maintain than a 200-character inline IIFE. The function is defined once and referenced by name in the `onclick` attribute.
- **Sign-out requires a different pattern.** For PocketBase apps, the sign-out button's inline handler should clear the auth token directly: `onclick="localStorage.removeItem('pocketbase_auth');location.replace('login.html?cb='+Date.now());"` — this bypasses the SDK entirely. For Firebase apps, use `onclick="firebase.auth().signOut();window.location.href='login.html';"`
- **Missing import paths are the #1 cause of silent handler failures.** When a dropdown opens but buttons inside it don't work, the JS module loaded on click has a broken relative import. For `shared/header-functionality.js`: `./pocketbase.js` → `shared/pocketbase.js` (doesn't exist, file is in root). Fix: `../pocketbase.js`. Also replace stale Firebase imports (`../firebase.js` → `../pocketbase.js`)
- For full PocketBase migration debugging patterns, see `references/pocketbase-migration-pitfalls.md`
- **Selector escaping in inline scripts breaks close-button detection.** When an inline `<script>` block's click handler uses `e.target.closest('[aria-label=\\\\\\\"Close\\\\\\\"]')`, the double-escaping produces a selector matching the literal attribute value `\"Close\"` (with quotes) instead of the actual value `Close`. The handler silently never fires. Fix: use `[aria-label=\"Close\"]`. See `references/frontend-debugging-patterns.md` § \"Escaped Characters in Inline Scripts Breaking Selectors.\"
- **`classList.add('hidden')` can be silently overridden by the CSS cascade.** Tailwind's `.hidden { display: none }` (0,0,1,0 specificity) can lose to `.modal { display: flex }` if the Tailwind CDN injects its stylesheet before the page's own CSS (both have equal single-class specificity — the last one wins). When a modal uses `class=\"modal ... flex ...\"`, and the page's `style.css` defines `.modal { display: flex }`, adding the `hidden` class via JavaScript may have no visible effect. **Nuclear fix:** use `element.style.setProperty('display', 'none', 'important')` — an inline style with `!important` has the highest possible specificity and overrides everything. Pair this with `element.style.removeProperty('display')` in `openModal()` to cleanly clear it. Always add a visual diagnostic (brief colored outline flash) to confirm the close function actually ran vs. silently failed.
- **\"Toggles save on this page but show off on other pages\" is a specific diagnostic signal.** It means `initializeSettings()` is called on the current page (so settings load/save works) but NOT on the other pages. See `references/cross-page-settings-desync.md` for the full diagnosis, fix pattern, and verification checklist across multi-page apps.
- **"Toggles save but modal won't close" is a specific diagnostic signal.** When `populateSiteSettingsForm()` runs and checkboxes respond to user input, but clicking X/Cancel/Save on the SAME modal does nothing, the form-reading code path is healthy but the event-handler-attachment code path has failed. Two common root causes: (a) the inline IIFE's close-button detection has broken selector escaping (see above), or (b) the module-level `setupUnifiedSiteSettingsModalHandlers()` silently returned because DOM elements weren't available at script load time. Fix: add direct `onclick` attributes on the modal buttons (bypasses both causes), and ensure any Save button's onclick bridges to module-scoped functions via `window.*` (see `references/inline-script-bridging-checklist.md` §3).
- For adapter-level Firebase compatibility checks (`getDocs`/`onSnapshot` snapshot contract), see `references/pocketbase-adapter-firestore-compat.md`.
- For the silent setTimeout error pattern and its detection via global override, see `references/silent-setTimeout-errors.md`.

- `references/jspdf-pitfalls.md` — jsPDF rendering pitfalls: fill-over-text ordering, invisible borders from low contrast, yPos drift after background boxes, duplicate variable scoping
- `references/dropdown-teleport-pattern.md` — Portal/teleport pattern for dropdowns trapped in stacking contexts: move to body-level root, position with getBoundingClientRect, scroll-aware repositioning.
- `references/compact-header-pattern.md` — Session-specific: compact nav-bar-only header template, z-index fix, CSS selector duplications, and per-page conversion table for the 4-page web app.
- `references/nginx-server-block-diagnosis.md` — Diagnosing nginx `sites-available` vs `sites-enabled` mismatches when served content doesn't match edited files.
- `references/frontend-debugging-patterns.md` — Diagnostic patterns: JS-overrides-HTML, missing script imports, class-based dark mode search, mobile responsiveness pitfalls. (Absorbed from `frontend-debugging`.)
- `references/browser-inner-scroll-debugging.md` — Browser tool scroll workaround for React SPAs with inner-scrolling layouts. Diagnosing and fixing off-viewport button clicks.
- `references/cross-page-settings-desync.md` — Cross-page settings desync: missing `initializeSettings` call on one page, PocketBase onSnapshot one-shot limitation, verification checklist.
- `references/inline-script-bridging-checklist.md` — Checklist for adding plain `<script>` blocks to ES module pages: `closeModal`, `escapeHtml`, `window.*` bridges, and nginx CORS proxy setup.
- `references/reference-build-parity-and-live-validation.md` — Fast reference→target HTML parity workflow + evidence-first Playwright runtime validation (including Ubuntu 26 Chrome-channel workaround).


## 11. Inline Tab-Switching Fallback

### The Problem

A page has tab navigation (e.g., "Active Repair Orders" / "Repair Order History" / "Quote Generator") where clicking a tab switches between content panels. The JS module that attaches the tab-switch event handler fails to load (same root cause: broken ES module import chain). The tabs display but clicking them does nothing.

### The Fix

Add a **global `switchTab` function** in a non-module `<script>` block, and call it via inline `onclick` on each tab button:

**Step 1: Define the tab map and switch function.**
```html
<script>
window.switchPageTab=function(tabId){
    var tabs={'active-tab':'active-content','history-tab':'history-content','generate-tab':'generate-content'};
    var contentId=tabs[tabId];if(!contentId)return;
    // Deactivate all tabs
    document.querySelectorAll('.nav-pill').forEach(function(b){b.classList.remove('active');});
    // Activate clicked tab
    var btn=document.getElementById(tabId);if(btn)btn.classList.add('active');
    // Hide all content panels
    Object.values(tabs).forEach(function(id){var el=document.getElementById(id);if(el)el.classList.add('hidden');});
    // Show target content
    var content=document.getElementById(contentId);if(content)content.classList.remove('hidden');
};
</script>
```

**Step 2: Add `onclick` to each tab button.**
```html
<button id="active-tab" class="nav-pill active" onclick="switchPageTab('active-tab')">Active</button>
<button id="history-tab" class="nav-pill" onclick="switchPageTab('history-tab')">History</button>
<button id="generate-tab" class="nav-pill" onclick="switchPageTab('generate-tab')">Generate</button>
```

### When to Apply

- Tabs render but clicking them doesn't switch content panels
- Same root cause as other module-failure symptoms (missing shared dependency)
- Useful alongside the universal modal manager and inline onclick for buttons

### Pitfalls

- **Tab map must match the HTML.** The `tabs` object maps button IDs to content div IDs. Verify both sets of IDs exist in the page.
- **Default active tab.** The first tab in the HTML should have `class="nav-pill active"` and its content panel should NOT have `class="hidden"`. Other panels start hidden.
- **Name the function uniquely per page** if multiple pages have different tab structures (e.g., `switchRepairTab` vs `switchDashboardTab`).
- **⚠️ CSS/JS class name mismatch — JS toggles one class, CSS styles another.** This is the #1 cause of "tab visually stays highlighted on the first tab no matter which tab is clicked."

  **The pattern:** The HTML starts with `class="nav-pill active"` on the first tab. The CSS defines `.nav-pill.active { background-color: ...; box-shadow: ...; font-weight: ... }` — the `active` class IS the visual highlight. But the JS `switchMainTab()` function only toggles `tab-active`/`tab-inactive` classes (which control border-color and text color, NOT the pill background/shadow). The `active` class from the initial HTML is **never removed** by the JS. Result: clicking tabs correctly swaps content panels, but the first tab permanently retains its blue highlighted appearance.

  **Fix:** In `switchMainTab()`, add `'active'` to `classList.remove()` (when deactivating all tabs) and to `classList.add()` (when activating the clicked tab):

  ```javascript
  // Deactivate all — remove both the JS class AND the CSS visual class
  tabElement.classList.remove('tab-active', 'active');
  tabElement.classList.add('tab-inactive');
  
  // Activate clicked — add both
  activeTabElement.classList.remove('tab-inactive');
  activeTabElement.classList.add('tab-active', 'active');
  ```

  **Diagnosis:** The content switches but the visual highlight stays stuck on one tab. Inspect the tab buttons in DevTools — the `active` class will still be present on the first tab button after clicking other tabs. To confirm, check:
  - Which CSS selector controls the **visual appearance** (background, shadow, weight)? Search for `.nav-pill.active` or similar.
  - Which classes does the JS `switchMainTab()` function toggle? Search for `classList.*('tab-')`.
  - If they differ, that's the bug.

  **Broader pattern:** This isn't limited to tabs. Any UI toggle where CSS styles via class `X` but JS toggles class `Y` will exhibit the same "visual state stuck" bug. Always verify class name alignment between CSS and JS when a visual state doesn't update on interaction.


## 12. Sync Auth Guard (Before Modules Load)

### The Problem

Module-based auth checks (e.g., `onAuthStateChanged` in `dashboard.js`) run asynchronously — the page content may flash before the redirect fires, or the check may fail entirely due to broken imports or cached stale modules. The user can access protected pages (dashboard, repair-orders, etc.) without being logged in.

### The Fix

Add a **synchronous inline script immediately after `<body>`** that checks localStorage before any module loads, before any rendering:

**Protected pages (dashboard, etc.):**
```html
<body class="...">
<script>if(!localStorage.getItem('pocketbase_auth')){location.replace('login.html?cb='+Date.now())}</script>
```

**Login page (inverse guard — redirect to dashboard if already logged in):**
```html
<body class="...">
<script>if(localStorage.getItem('pocketbase_auth')){location.replace('index.html?cb='+Date.now())}</script>
```

### Why This Works

- **Synchronous** — runs before any `<script type="module">`, before any DOM rendering, before any async operations
- **No imports** — pure JS, no dependencies, no cache issues
- **Uses `location.replace()`** — prevents back-button loops (user can't go back to the protected page after redirect)
- **PocketBase-specific key** — `pocketbase_auth` is the default localStorage key used by the PocketBase JS SDK

### Applying to All Pages

Every protected page gets the same guard (checking `pocketbase_auth` exists). The login page gets the inverse (redirect to dashboard if auth exists).

```bash
# Verify all pages have the guard
curl -sk https://site.com/page.html | grep "location.replace('login.html')"
```

### Pitfalls

- **Token may be expired** — localStorage key exists but token is invalid. The module-level auth check handles this as a second pass: the SDK will reject the expired token and redirect. The sync guard is a first-pass filter that catches the common case (no token at all). **⚠️ Redirect loop risk:** If the login page has the inverse guard (`if(auth)→redirect to dashboard`) AND the protected page bounces back on auth failure WITHOUT clearing the stale token from localStorage, the expired token survives → login page sees it again → redirects to dashboard → infinite loop. **Fix:** Call `localStorage.removeItem('pocketbase_auth')` before EVERY redirect back to the login page. Apply this to ALL pages that redirect on auth failure — dashboard.js, repair-orders.js, appointments.js, and any other protected page. Do NOT rely on the SDK's `authStore.clear()` alone for pages that bounce via `window.location.href` — the inline sync guard (which runs before any SDK loads) checks localStorage directly and will still see the stale key.
- **CSP blocks the guard** — if the page has a Content Security Policy without `'unsafe-inline'` (common on Firebase-hosted index.html templates), the sync guard script is silently blocked. The guard works on other pages but not the one with the CSP. See Section 13 for diagnosis and fix.
- **Stale cache hides the guard** — if the user's browser cached the page BEFORE the guard was added, `location.replace('login.html')` may load a stale cached login page. Always add `?cb='+Date.now()` to redirect targets (see Section 2 for the cache-busting pattern).
- **Different auth storage keys** — PocketBase uses `pocketbase_auth` by default. Firebase uses prefixed keys. Adjust the localStorage key per backend.
- **Don't put it in `<head>`** — placing the script in `<head>` may cause it to run before the page is parsed, which is fine functionally but may trigger duplicate redirects. Place it right after `<body>` for clean behavior.
- **Use `location.replace()` not `location.href`** — `replace()` doesn't add to browser history, so the back button won't loop the user back to the page they just got redirected from.
- `references/repair-orders-header-fix.md` — Session-specific: adding mobile nav panel + missing script to repair-orders.html
- `references/ocr-parser-architecture.md` — Rule-based OCR parser v2: row-anchor splitting, consume-and-destroy extraction, cross-block name carry. Architecture reference for the ShopProQuote appointment screenshot parser (parseWithRules in appointments.html).

## 13. CSP Blocking Inline Scripts (Silent Failure)

### The Problem

After adding inline `<script>` tags and `onclick` attributes to fix broken UI (auth guards, dropdown toggles, sign-out buttons), **nothing works on one page but everything works on others.** The fixes are in the served HTML (verified with `curl | grep`). The browser console shows no visible errors the user would notice. The page just… doesn't respond.

### Root Cause

A **Content Security Policy** in the page's `<meta>` tag blocks inline scripts. The CSP on the broken page has `script-src 'self' <CDN domains>` but **no `'unsafe-inline'`**. This silently blocks:

- All `<script>...code...</script>` tags without a `src` attribute
- All `onclick="..."`, `onsubmit="..."` and other inline event handlers
- All `javascript:` URLs

The browser refuses to execute them. The only visible sign: DevTools Console shows messages like *"Refused to execute inline script because it violates the following Content Security Policy directive…"*

### Why Only SOME Pages Are Affected

In multi-page static apps, it's common for **only one page** to have a CSP (often the main page like `index.html`, copied from a template with security hardening). Other pages (`repair-orders.html`, etc.) have no CSP and work fine. This asymmetry is the key diagnostic signal.

### Diagnosis

Compare the CSP between a working page and the broken page:

```bash
curl -sk https://site.com/index.html | grep -o 'script-src[^;]*'
curl -sk https://site.com/repair-orders.html | grep -o 'script-src[^;]*'
# Broken page: script-src 'self' https://cdn...  ← no 'unsafe-inline'
# Working page: <empty>                         ← no CSP at all
```

### The Fix

Add `'unsafe-inline'` to the `script-src` directive:

```html
<!-- BEFORE (blocks inline scripts) -->
<meta http-equiv="Content-Security-Policy" 
  content="...script-src 'self' https://cdn.example.com...">

<!-- AFTER (allows inline scripts) -->
<meta http-equiv="Content-Security-Policy" 
  content="...script-src 'self' 'unsafe-inline' https://cdn.example.com...">
```

Also add `'unsafe-inline'` to `style-src` if inline `<style>` blocks or `style="..."` attributes are used.

### Pitfalls

- **CSP meta tags only apply to that page.** A fix on `index.html` doesn't fix other pages — but they may not need fixing if they never had a CSP
- **`'unsafe-inline'` is a security relaxation** — it allows any inline script to execute. If the app accepts user-generated content, consider using nonces or hashes instead
- **Don't remove the CSP entirely** unless the page has no security concerns. Adding `'unsafe-inline'` is a targeted fix
- **The error is visible in DevTools Console (F12)** — but users rarely open it. When debugging remote, always check the CSP if inline fixes don't work on a specific page


## 14. Universal Modal Manager (Event Delegation)

### The Problem

A multi-page app has 6–15 modals across pages. Each modal has a close (X) button, and modals should close when clicking the dark backdrop. But JS modules that attach these handlers fail to load (broken imports, async race conditions, stale cache). Result: modals open (via inline onclick triggers) but won't close — X buttons and backdrop clicks do nothing.

Per-element listeners via `DOMContentLoaded` also fail for **dynamically created modals** (edit-task, create-appointment) that are injected after page load.

Additionally, **ES module chain failures** are a silent killer. When a page loads 3–5 `<script type="module">` tags (e.g., `pocketbase.js` → `dashboard.js` → `financial-dashboard.js` → `quote-tab-manager.js`), a single broken import in the chain prevents ALL module-level event listeners from registering. Buttons like "Financials", "View/Add Tasks", "Quick Add Task", and "Change Password" all die at once with no visible error.

### The Fix

Use a **single event-delegation handler on `document`** in the **capture phase**. This catches ALL clicks before other handlers, works for dynamically created elements, needs zero per-element registration, and is immune to ES module failures. Add a global `openModal()` function alongside `closeModal()` so inline `onclick` on buttons can open modals directly.

```html
<script>
(function(){
    // ---- HELPERS ----
    window.closeModal=function(id){
        var m=document.getElementById(id);
        if(m){m.classList.add('hidden');m.setAttribute('aria-hidden','true');}
    };
    window.openModal=function(id){
        var m=document.getElementById(id);
        if(m){m.classList.remove('hidden');m.setAttribute('aria-hidden','false');m.style.zIndex='100000';}
    };
    function hasClassToken(el,prefix){
        for(var i=0;i<el.classList.length;i++)
            if(el.classList[i].indexOf(prefix)===0) return true;
        return false;
    }
    function findOuterModal(el){
        var m=el;while(m&&!m.id&&m.parentElement&&m.parentElement!==document.body)m=m.parentElement;
        return m;
    }
    function isVisible(el){
        return el.classList.contains('hidden')===false&&el.style.display!=='none';
    }
    // ---- EVENT DELEGATION (capture phase) ----
    document.addEventListener('click',function(e){
        // 1) Close button click (aria-label="Close" or data-close-modal)
        var closeBtn=e.target.closest('[aria-label="Close"],[data-close-modal]');
        if(closeBtn){
            e.preventDefault();e.stopPropagation();
            var id=closeBtn.getAttribute('data-close-modal');
            if(id){closeModal(id);return;}
            var modal=closeBtn.closest('.modal,[role="dialog"],.fixed');
            if(modal&&isVisible(modal)){
                var outer=findOuterModal(modal);
                if(outer.id){closeModal(outer.id);}
                else{modal.classList.add('hidden');modal.setAttribute('aria-hidden','true');}
            }
            return;
        }
        // 2) Cancel button click (any button with id starting with 'cancel-')
        if(e.target.id&&e.target.id.indexOf('cancel-')===0){
            var modal=e.target.closest('.modal,.fixed');
            if(modal&&isVisible(modal)){
                var outer=findOuterModal(modal);
                closeModal(outer.id||modal.id);
            }
            return;
        }
        // 3) Backdrop click — prefix-match bg-opacity-* (Tailwind), NOT exact match
        var target=e.target;
        if(target.classList.contains('modal')||
           (target.classList.contains('fixed')&&
            target.classList.contains('inset-0')&&
            hasClassToken(target,'bg-opacity'))){
            closeModal(target.id||'');
            var pm=target.closest('.modal');
            if(pm&&pm!==target) closeModal(pm.id||'');
            return;
        }
    },true);  // <-- capture phase is critical
})();
</script>
```

### How It Works

| Feature | Mechanism |
|---------|-----------|
| Close (X) buttons | `e.target.closest('[aria-label="Close"]')` finds any close button in the click path |
| `data-close-modal` attribute | Buttons with `data-close-modal="modal-id"` close that modal by ID |
| Cancel buttons | Any `id="cancel-*"` button auto-closes its parent modal — no per-button wiring needed |
| Backdrop clicks | Uses `hasClassToken(target,'bg-opacity')` — **prefix match** to catch `bg-opacity-50`, `bg-opacity-20`, etc. |
| Dynamic modals | No DOM-ready registration needed — the `document` listener catches everything |
| Capture phase (`true`) | Fires BEFORE other handlers, won't be blocked by `stopPropagation()` |
| `openModal()` global | Buttons use `onclick="openModal('modal-id')"` — immune to broken module imports |

### Placement

Add as the **last `<script>` block before `</body>`** on every page. Verify with curl:

```bash
for f in index.html repair-orders.html appointments.html customers.html; do
  curl -sk https://site.com/$f | grep -c 'window.closeModal'
done
# Expected: 1 for each page
```

### Adding onclick to Buttons

For every button that opens a modal and relies on a JS module handler, add an inline fallback:

```html
<!-- BEFORE (silent failure if JS module doesn't load) -->
<button id="open-financials-modal" class="action-card ...">Financials</button>

<!-- AFTER (works always, module handler still present as enhancement) -->
<button id="open-financials-modal" onclick="openModal('financial-dashboard-modal')" class="action-card ...">Financials</button>
```

### When to Apply

- User reports one or more "broken buttons" on any page
- **IMPORTANT: When the user says "make everything work" or "check every page," do NOT just fix the buttons they explicitly named.** Systematically audit: (a) every interactive element on every page, (b) the JS module import chain, (c) shared infrastructure (CSP, cache headers, nginx config). The user is saying "I want a complete audit, not one-at-a-time bug fixes." If you get a second report of broken buttons in the same session, you've already failed this standard — switch to root-cause diagnosis immediately.
- Modals open (via inline onclick) but X buttons and backdrop clicks do nothing
- JS module handlers for close behavior are broken or unreachable
- Dynamically created modals need close behavior without per-element listener registration
- Multiple buttons across a page all stopped working simultaneously (ES module chain failure)
- Consolidating scattered modal-close logic into one maintainable handler

### Pitfalls

- **⚠️ `classList.contains('bg-opacity')` does NOT match Tailwind's `bg-opacity-50`.** Tailwind generates separate class tokens: `bg-opacity` and `50` are two distinct tokens in the class list. `contains('bg-opacity')` checks for an EXACT token match — it will never find `bg-opacity-50`. Use `hasClassToken(el, 'bg-opacity')` which does **prefix matching** across all class tokens. This is the #1 cause of "backdrop clicks don't close the modal."
- **Capture phase is critical.** Without `true` as the third argument to `addEventListener`, the handler runs in bubble phase and may be blocked by `stopPropagation()` in other handlers
- **`data-close-modal` is the cleanest pattern for new modals.** Always add `data-close-modal="modal-id"` to close buttons so they work without code changes
- **Prefer this over per-element listeners.** Event delegation is more maintainable, handles dynamic content, and is immune to module loading failures
- **Does NOT handle ESC key.** For keyboard accessibility, add a separate `keydown` listener for `Escape` to close the topmost visible modal
- **Cancel buttons need their own handler.** Cancel buttons typically don't have `aria-label="Close"` — they're labeled "Cancel" and their `id` starts with `cancel-`. The handler catches these by ID prefix matching, so no per-button wiring is needed
- **NEVER set `data-close-modal` on a SAVE button when using a capture-phase IIFE.** The IIFE's `addEventListener('click', ..., true)` matches `[data-close-modal]`, calls `stopPropagation()`, and closes the modal BEFORE any save handler fires. For close/cancel buttons this is correct. For a Save button, it kills the save. **Diagnosis signal:** "Save closes modal but settings don't persist" + page has a capture-phase IIFE + save button has `data-close-modal`. **Fix:** remove `data-close-modal` from the save button; let a non-capture handler (inline `onclick` or `addEventListener` without capture) handle save-then-close.
- **ES module failures cascade silently.** When a page has `script[type="module"]` tags that chain-import through 3–4 files (e.g., `pocketbase.js` → `dashboard.js` → `financial-dashboard.js` → `quote-tab-manager.js`), a single broken import in the chain causes ALL modules to fail. Every button whose handler was registered in that chain goes dead — not just one. The inline onclick pattern bypasses this entire dependency chain.
- **Inline scripts need their own global bridge.** When adding a plain `<script>` block that needs `closeModal`, `escapeHtml`, or module-level data functions, these are NOT globally available (they're ES module exports only). See `references/inline-script-bridging-checklist.md` for the complete verification checklist.


## 15. When Every Button on Every Page Is Broken — Check Shared Dependencies

### The Problem

The user reports multiple unrelated buttons broken: "Financials card doesn't open," "Add Tasks does nothing," "Repair Order History tab does nothing," "Generate Quote does nothing," "hamburger menu broken on all pages." The pattern is: **every interactive element that relies on JS modules is dead across all pages simultaneously.**

### Root Cause

A **single missing shared utility file** that is imported by multiple page modules. In this session, `shared/sanitize.js` (exporting `escapeHtml()`) was missing — and it was imported by **9 different modules** across all pages:

```
repair-orders.js, dashboard.js, customers.js, appointments.js,
settings.js, quote-tab-manager.js, invoice-manager.js,
customer-lookup.js, ro-active.js
```

When an ES module can't resolve an import, it fails **silently** — no console error visible to users, no broken page layout, just… every event listener from every module never gets attached.

### Diagnosis

**Step 1: Check if it's a module failure, not a per-page issue.**
If the same class of failure (buttons dead, modals won't close, tabs don't switch) occurs on ALL pages, the problem is in shared infrastructure, not individual page code.

**Step 2: Find the shared dependency.**
Map the import chain for every `<script type="module">` on each page:

```bash
cd /path/to/site
# For each module, trace imports
for js in dashboard.js repair-orders.js appointments.js customers.js; do
  head -15 "$js" | grep "^import "
done
# Find common imports across modules
```

**Step 3: Check if every imported file EXISTS.**
```bash
cd /path/to/site
for js in $(find . -name '*.js' -type f); do
  imports=$(grep -oP "from\s+['\"](\.[^'\"]+)['\"]" "$js" | sed "s/from ['\"]//;s/['\"]//")
  for imp in $imports; do
    resolved=$(dirname "$js")/"$imp"
    [ -f "$resolved" ] || echo "MISSING: $js imports '$imp' → $resolved"
  done
done
```

**Step 4: Create the missing file.**
The simplest fix is a minimal implementation of the missing export:
```javascript
// shared/sanitize.js
export function escapeHtml(str) {
    if (str == null) return '';
    return String(str)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
}
```

### The Signal

The key diagnostic signal: **"buttons that worked before no longer work, and it's the same class of failure on multiple pages."** This is never a per-button wiring issue — it's always a shared dependency that silently breaks the entire module chain. Don't patch individual buttons when the root cause is a missing import.

### Pitfalls

- **ES module failures are silent.** Unlike `<script src="...">` which shows 404s in the Network tab, `import { x } from './missing.js'` fails without any visible error for most users. You must check the browser DevTools Console (F12) or trace imports manually.
- **One missing file can kill 9+ modules.** Don't be misled by the variety of symptoms — Financials broken + History tab broken + hamburger broken + modals won't close. They all trace back to the same missing file.
- **The missing file may be trivial.** `sanitize.js` was 8 lines. The damage wasn't proportional to the file's size.
- **Belt-and-suspenders still matters.** After fixing the root cause, still add inline onclick fallbacks to critical buttons. If another shared dependency breaks tomorrow, the inline handlers keep the app usable.
- **cdn imports are NOT checked by this tool.** The import checker script only checks local file paths. CDN URLs import fine (or throw a network error in the console).

### When to Apply

- User says "nothing works" or "every button is broken" or "multiple pages have the same problem"
- A previous session fixed one page's buttons, but now ALL pages have broken buttons
- You've added inline onclick to multiple buttons and the user keeps finding more broken ones — stop patching and find the shared dependency

## 11. Per-Page Replication of Inline Fixes

### The Problem

A multi-page static web app shares a common header (nav bar, user dropdown, logout button) across 4+ pages. When the JS module handler for a shared UI element breaks, the fix must be applied to ALL pages — not just the one the user reported. Skipping a page leaves the same bug lurking.

### The Workflow

1. **Fix one page first** — apply the inline `onclick` + close script to the page the user is on (usually `index.html`)
2. **Verify** — have the user confirm it works on that page
3. **Find all pages with the same element** — search across all HTML files:
   ```bash
   grep -ln 'id="user-menu-btn"' *.html
   ```
4. **Apply the same patch to each page** — the button HTML is typically identical, so the same `old_string` works across pages
5. **Add the close script to each page** — find the unique `<script>` tag before `</body>` on each page and append the inline close handler
6. **Verify with curl** — confirm the inline onclick is live on each page:
   ```bash
   for f in index.html repair-orders.html appointments.html customers.html; do
     curl -sk https://site.com/$f | grep -c 'onclick.*user-dropdown'
   done
   ```

### Pitfalls

- **Close scripts need per-page context.** Each page ends with different `<script type="module">` tags before `</body>`. Use the last script tag on each page as the anchor for `old_string` to make the patch unique
- **Don't forget `login.html`** — but check if it even has the same header. Login pages often have different layouts
- **Cache headers compound the problem.** If nginx has `immutable` set, the user won't see fixes on ANY page until they hard-refresh. Fix the cache FIRST (Section 2), then the inline handlers
- **Check that ALL dropdown items exist on ALL pages.** In a PocketBase migration, `customers.html` was missing the Sign Out button entirely — the dropdown had Account Settings and Site Configuration but no logout section. Search for `id="logoutBtn"` across all pages; add the missing button + divider HTML if absent
- **Broken relative imports after backend migration.** When converting a multi-page app from Firebase to PocketBase, files in `shared/` may have dynamic `import('./pocketbase.js')` that resolves to `shared/pocketbase.js` — but `pocketbase.js` lives in the **root**. Fix: change to `import('../pocketbase.js')`. Also check that ALL dynamic imports reference the new backend, not stale Firebase paths (`'../firebase.js'` → `'../pocketbase.js'`)

**⚠️ Batch patching across pages — verify with grep, NOT just patch return values.** When using `execute_code` to apply the same patch to multiple pages, the `patch()` function returns success even if the `old_string` matched to in zero pages (e.g., wrong hex color `#2563eb` vs actual `#3b82f6`). After any batch patch, grep the patched files for the NEW string to confirm each one took. Two common silent-failure causes:
- **Different color hex values across pages** — one page uses `#2563eb`, another uses `#3b82f6`. A single `old_string` only matches one.
- **Whitespace/indentation differences** — macOS vs Linux line endings, mixed tabs/spaces, or extra indentation in one page's template.

Example verification:
```bash
for f in index.html repair-orders.html appointments.html customers.html; do
  echo -n "$f: "
  grep -c "NEW_STRING" "$f"
done
# Every page should return ≥ 1. If any return 0, the batch patch missed that page.
```

### PocketBase Sign-Out Bypass Pattern

When PocketBase SDK sign-out handlers fail (due to import chain errors, stale cache, or SDK bugs), use a direct localStorage clear:

```html
<button onclick="localStorage.removeItem('pocketbase_auth');window.location.href='login.html';">Sign Out</button>
```

This is the PocketBase equivalent of clearing Firebase auth — PocketBase stores its auth token in `localStorage` under the key `pocketbase_auth`. Removing it and redirecting to `login.html` effectively signs the user out, bypassing the entire SDK.

## 16. Adapter Contract Beats Page-Level Patching (Firebase → PocketBase)

### The Problem

After a migration, teams often patch dozens of page-level buttons/modals/tabs because "nothing works" across dashboard, appointments, customers, and quote flows. In many cases, page code is already correct — the break is in the backend adapter contract.

### Root Cause Pattern

`pocketbase.js` does not faithfully emulate Firestore snapshot semantics expected by existing modules.

Most common breakpoints:

- `getDocs(...)` returns a raw array instead of QuerySnapshot-like object
- missing snapshot fields/methods: `docs`, `size`, `empty`, `forEach`
- adapter only supports `getDocs(query(...))`, but app also uses `getDocs(collection(...))`
- `getDoc(...)` returns `exists` as a boolean property instead of Firestore-style function `exists()`
- `getDoc(...)` omits `ref` shape expected by some edit/detail flows
- `onSnapshot(...)` callback shape mismatches query vs doc callers

When this happens, you'll see widespread failures in app-level features even if HTML/JS files are nearly identical to a working reference.

### Required Compatibility Contract

For this class of app, adapter output must satisfy Firestore-style callers:

- `querySnapshot.docs.map(...)`
- `if (querySnapshot.empty) ...`
- `querySnapshot.size`
- `querySnapshot.forEach(...)`

If any of these are used in the codebase, returning a plain array is a hard bug.

### addDoc userId Injection (Sub-Collection Pattern)

Many code paths create records via `collection(db, 'users', uid, 'services')`. The adapter tracks `_userId` on the ref but `addDoc()` must inject `userId` into the stored record so subsequent `where("userId", "==", uid)` queries work:

```javascript
async function addDoc(collRef, data) {
    const collName = collRef._name;
    const pbData = convertToPB(data);
    if (collRef._userId && !pbData.userId) {
        pbData.userId = collRef._userId;
    }
    const record = await pb.collection(collName).create(pbData);
    return { id: record.id };
}
```

Without this, newly created records (services, customers, appointments via sub-collection refs) silently fail to appear in filtered queries — appears as a "save didn't work" bug to the user.

### Recommended Workflow

1. Restore target page parity with known-good reference first.
2. Swap backend import entry (`firebase.js` → `pocketbase.js`) only.
3. Validate/fix adapter contract (`getDocs`, `onSnapshot`, snapshot shape).
4. Re-test the four core user flows before adding inline fallbacks:
   - quote generation
   - appointment setting
   - dashboard functions
   - customer page functions

### Pitfall

Do **not** default to mass inline `onclick` or duplicate modal managers as first response. If failures are cross-page and simultaneous, suspect shared adapter contract first. Page-level bypasses can mask the real fault and create double-firing regressions.

## 17. Missing HTML Elements That JS References

### The Problem

A button click or search interaction does nothing. Console shows no errors. The JS code calls `document.getElementById('some-modal')` or `document.getElementById('some-button')` but the matching HTML element does not exist in the page. The JS silently gets `null` and stops — no error, no feedback.

This commonly happens after:

- Copying reference HTML over target, where the reference build lacks a modal that the target's JS expects.
- A migration that copies JS files but not the corresponding HTML template updates.
- Section collapse/expand patterns where the content section (e.g. `#services-content`) is missing or mis-named.

### Diagnosis

Check every element ID that the JS function references:

```bash
# Extract all getElementById calls from the relevant JS file
grep -oP "getElementById\('([^']+)'\)" path/to/file.js | sort -u

# Check each one exists in the HTML:
for id in $(grep -oP "getElementById\('([^']+)'\)" path/to/file.js | sed "s/getElementById('//;s/')//"); do
  grep -q "id=\"$id\"" path/to/page.html || echo "MISSING: $id"
done
```

### Common patterns in modal-heavy apps

| JS reference | Expected HTML | When it's missing |
|---|---|---|
| `#add-custom-service-modal` | Full modal with form fields | After HTML sync from a reference that didn't have it |
| `#custom-service-name` | Input field inside modal | When modal HTML was partially added |
| `#add-service-modal-settings` | Settings modal sub-modal | After migration when settings template wasn't ported |
| `#service-search-input` | Search input in quote tab | When collapsible section HTML differs between builds |

### The Fix

Add the missing HTML element. For modals, the full modal template needs:

```
<div id="modal-id" class="fixed inset-0 ... hidden">
  <div class="bg-white ...">
    <div class="sticky ...">Header with close button</div>
    <div class="p-6 space-y-4">
      <!-- form fields matching JS references -->
    </div>
  </div>
</div>
```

The quickest source for the correct modal HTML is the reference build (if available) or the app's working pages. Search for an existing modal with similar structure and adapt it.

### Pitfall

- **CSS may exist but HTML doesn't.** In this session, `style.css` had `#add-custom-service-modal { }` styling rules, and JS in `quote-tab-manager.js` referenced it, but no HTML element with that ID existed. Always check all three layers (CSS, JS, HTML) independently.
- **The missing element may be in a collapsed section.** If JS sets up event listeners inside a hidden div (`#services-content`), the listeners may never fire. Expand all collapsible sections during diagnosis.
- **Backend migration amplifies this.** When you swap HTML files (reference → target) but keep the target's JS, the new HTML may lack modals/inputs that the old JS expects. The fix is to add the missing HTML, not revert the HTML swap.

## 18. Silent Errors in setTimeout / Async Callbacks

### The Problem

A feature branch is fully wired — the button click triggers, the event handler fires, console logs confirm the code path. But the expected UI action (modal opening, page transition, notification) never happens. No console error, no catch block fires, nothing.

### Root Cause

The failing code runs **inside a `setTimeout` callback** that was scheduled **inside a `try/catch` block**. The `try/catch` exits (via `return` or `await`) before the `setTimeout` fires. When the callback executes later and throws, the error goes to the **global error handler**, not to the original `try/catch` — so any `catch` logic (like `showNotification`) never runs.

```javascript
try {
    // code runs, setTimeout is scheduled
    setTimeout(() => {
        // this fires LATER, after the try block has already exited
        problematicFunction();  // throws → invisible, uncaught
    }, 100);
    return;  // try/catch exits here
} catch (e) {
    showNotification('Error: ' + e.message);  // NEVER REACHED
}
```

This is the **#1 cause of "nothing happens" bugs in modal-heavy web apps** because:
- Flow chaining (click → confirm modal → close modal → setTimeout → open next modal) is common
- Each intermediate step uses `setTimeout` or event listeners whose contexts are separated from the original caller
- DOM element lookups (`getElementById`) fail silently when elements were never added to the HTML (see §17)

### Diagnosis

**Step 1 — Widen the error net.** Override `setTimeout` to wrap every callback in a `try/catch` that logs to console:

```javascript
const origSetTimeout = window.setTimeout;
window.setTimeout = function(fn, delay) {
    return origSetTimeout(function() {
        try { fn(); }
        catch(e) { console.error('setTimeout ERROR:', e); }
    }, delay);
};
```

Place this before the buggy interaction. It catches errors that normal console wouldn't surface.

**Step 2 — Override `getElementById` to detect null returns.** Null is not an error at the JS level, but it IS the bug:

```javascript
const origGetById = document.getElementById.bind(document);
document.getElementById = function(id) {
    const el = origGetById(id);
    if (!el) console.warn('getElementById returned null for:', id);
    return el;
};
```

**Step 3 — Check every `document.getElementById` call the handler makes.** If any one of them returns `null`, operations like `element.textContent = ...` throw `TypeError: Cannot set properties of null` — and that first null lookup is the actual bug.

### The Fix

Once the error is surfaced, fix the root cause (typically §17 — add the missing HTML element).

If the crash happens because a DOM element lookup inside a `setTimeout` returns null, add the missing element to the HTML.

### Pitfalls

- **Do NOT wrap individual lines in try/catch as the primary fix.** The issue is always a missing/incorrect DOM reference. Catching the error masks the real problem.
- **Playwright captures global errors.** If you're testing with Playwright and still see nothing, your error handler patching (Step 1) might not apply early enough. Inject the override via `page.evaluate()` before any user interaction.
- **The same pattern affects `requestAnimationFrame`, `Promise.then()`, and event listeners.** If the crash happens in a `.then()` callback of an unawaited Promise, the error is also uncaught at the caller level. The general rule: any callback whose parent context has already resolved/returned is a detached error zone.
- **Bundle this with §16 (Adapter Contract).** A common failure cascade: adapter method like `getROData()` returns plausible data but a downstream field access crashes on a missing property, inside a setTimeout that the try/catch has already abandoned. Fix the adapter contract first, then check the chains.

## 19. Finding JS Syntax Errors Without Linters (grep)

### The Problem

A Vanilla JS web app fails to load completely (e.g., UI is stuck on "Loading..."). The browser console may show a `SyntaxError`, or the page simply hangs because the script failed to parse. You are working in a constrained terminal environment without `node -c`, `eslint`, or other JS tooling, making it hard to find exactly where the syntax error (like a duplicate declaration) is in a large file.

### Root Cause

Vanilla JS throws a fatal parse-time `SyntaxError` if `const` or `let` is declared twice in the same scope. This prevents the entire script from executing. It often happens when merging code, handling API responses (`const result = ...`), or caching values in a long function block.

### The Fix / Diagnostic Technique

You can statically analyze the JS file for duplicate top-level or block-level variable declarations using `grep`, `awk`, `sort`, and `uniq -d` in the terminal:

```bash
# Find duplicate 'const' declarations
grep -P "^\\s*const \\w+ =" /path/to/file.js | awk '{print $2}' | sort | uniq -d

# Find duplicate 'let' declarations
grep -P "^\\s*let \\w+ =" /path/to/file.js | awk '{print $2}' | sort | uniq -d
```

Once you identify the duplicated variable name (e.g., `briefingText`), find its line numbers:
```bash
grep -n "^\\s*const briefingText =" /path/to/file.js
```

Rename the duplicate variable in the second instance (e.g., to `finalBriefingText`) to restore script execution.
