# SmartStack Frontend Compliance — Documentation, Testing & Gates

> **Loaded by:** step-03 (documentation generation), step-04 (compliance validation)
> **Purpose:** Documentation integration, form testing templates, and compliance gates.
> **Split from:** `smartstack-frontend.md` (sections 7-9) for context reduction.

---

## 7. Documentation Panel Integration (DocToggleButton)

> **EVERY list/detail page MUST include a `DocToggleButton` in its header.**
> This button opens the right-side documentation panel showing the module's user documentation.

### Component Import

```tsx
import { DocToggleButton } from '@/components/docs/DocToggleButton';
```

### Placement — Always in the page header actions area (top right)

```tsx
{/* Header with DocToggleButton */}
<div className="flex items-center justify-between">
  <h1 className="text-2xl font-bold text-[var(--text-primary)]">
    {t('{module}:title', 'Module Title')}
  </h1>
  <div className="flex items-center gap-2">
    <DocToggleButton />
    <button onClick={() => navigate('create')} className="...">
      {t('{module}:actions.create', 'Create')}
    </button>
  </div>
</div>
```

### How it Works

1. `DocToggleButton` uses `useDocPanel()` context (provided by the Layout)
2. On click → opens the `DocPanel` on the right side of the screen
3. The panel loads the module's documentation via iframe (`?embedded=true`)
4. Route → doc mapping is in `DocPanelContext.tsx` — maps current pathname to doc URL
5. Panel is resizable (20-60% width), size persists in localStorage

### Documentation Generation

After frontend pages are created, invoke the `/documentation` skill to generate:

| File | Content |
|------|---------|
| `src/pages/docs/business/{app}/{module}/doc-data.ts` | Data-driven documentation (~50-80 lines) |
| `src/pages/docs/business/{app}/{module}/index.tsx` | Page wrapper (~10 lines) using `DocRenderer` |
| `src/i18n/locales/fr/docs-{app}-{module}.json` | French doc translations (source language) |

The `DocRenderer` shared component renders all 8 documentation sections (overview, use cases, benefits, features, steps, FAQ, business rules, permissions, API endpoints) from the `doc-data.ts` file.

### Custom Doc URL (optional)

If the automatic route mapping doesn't work for your module, pass a custom URL:

```tsx
<DocToggleButton customDocUrl="/docs/human-resources/employees" />
```

### Rules

- Include `DocToggleButton` in header actions of every list page
- Include `DocToggleButton` in header actions of every detail page
- Create/Edit form pages do NOT need DocToggleButton (users don't read docs while filling forms)
- DocToggleButton is imported from `@/components/docs/DocToggleButton` (shared component)
- The Layout already provides `DocPanelProvider` — no additional wrapping needed
- Documentation content is generated by the `/documentation` skill AFTER frontend pages exist

---

## 7b. Checklist for /apex Frontend Execution

Before marking frontend tasks as complete, verify:

- [ ] All page imports use `React.lazy()` with named export wrapping
- [ ] `<Suspense fallback={<PageLoader />}>` wraps all lazy components in routes
- [ ] Translation files exist for **all 4 languages** (fr, en, it, de) in `src/i18n/locales/`
- [ ] All `t()` calls include namespace prefix AND fallback value
- [ ] No hardcoded strings in JSX — all text goes through `t()`
- [ ] CSS uses variables only — no hardcoded Tailwind colors (POST-CHECK C9)
- [ ] Pages follow loading → error → content pattern
- [ ] Pages use `src/pages/{App}/{Module}/` hierarchy
- [ ] API calls use generated hooks or `apiClient` (never raw axios)
- [ ] Components use DataTable/EntityCard (never raw HTML `<table>`)
- [ ] FK fields use `EntityLookup` — see `smartstack-frontend.md` section 6
- [ ] Create/Edit forms are full pages with own routes — see `smartstack-frontend.md` section 3b
- [ ] `DocToggleButton` present in header of every list/detail page (see section 7)
- [ ] `/documentation` skill invoked to generate module doc-data.ts

---

## 7c. Cross-Tenant Entity UI Patterns

> **For optional and scoped tenant entities, the frontend MUST provide UI controls to set the scope/visibility.**

### Scope Types

| Type | Behavior | Use case |
|------|----------|----------|
| **Optional** | Entity can be tenant-specific OR shared (binary choice) | Data that can belong to one org or all orgs |
| **Scoped** | Entity has explicit scope enum: Tenant / Shared / Platform | Data with multiple visibility levels |

### Scope Selector in Create Forms (Optional Entities)

For `optional` tenant entities, add a toggle in the create form allowing the user to decide:

```tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';

export function EntityCreatePage() {
  const { t } = useTranslation(['{module}']);
  const [formData, setFormData] = useState({
    name: '',
    isShared: false,  // User decision: tenant-specific (false) or shared (true)
  });

  const handleScopeChange = (value: string) => {
    setFormData({ ...formData, isShared: value === 'shared' });
  };

  return (
    <div className="space-y-6">
      {/* ... form header ... */}

      <form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
        {/* Name field */}
        <div>
          <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
            {t('{module}:form.name', 'Name')}
          </label>
          <input
            type="text"
            value={formData.name}
            onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
            className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] rounded-[var(--radius-input)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)]"
            required
          />
        </div>

        {/* Scope selector — binary toggle for optional entities */}
        <div className="space-y-2">
          <label className="block text-sm font-medium text-[var(--text-primary)]">
            {t('common:scope', 'Scope')}
          </label>
          <select
            value={formData.isShared ? 'shared' : 'tenant'}
            onChange={(e) => handleScopeChange(e.target.value)}
            className="w-full px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)] text-[var(--text-primary)]"
          >
            <option value="tenant">
              {t('common:scope.tenant', 'My Organization')}
            </option>
            <option value="shared">
              {t('common:scope.shared', 'Shared (All Organizations)')}
            </option>
          </select>
          <p className="text-xs text-[var(--text-secondary)]">
            {formData.isShared
              ? t('common:scope.shared.hint', 'This data will be accessible to all organizations')
              : t('common:scope.tenant.hint', 'This data will only be visible to your organization')}
          </p>
        </div>

        {/* Actions */}
        <div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
          <button type="button" onClick={() => navigate(-1)} className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]">
            {t('common:actions.cancel', 'Cancel')}
          </button>
          <button type="submit" disabled={submitting} className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)] disabled:opacity-50">
            {submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
          </button>
        </div>
      </form>
    </div>
  );
}
```

### Scope Selector in Create Forms (Scoped Entities)

For `scoped` entities with explicit enum values (Tenant, Shared, Platform), use a dropdown with all scope options:

```tsx
export function EntityCreatePage() {
  const { t } = useTranslation(['{module}']);
  const [formData, setFormData] = useState({
    name: '',
    scope: 'Tenant',  // Enum: 'Tenant' | 'Shared' | 'Platform'
  });

  return (
    <form onSubmit={handleSubmit} className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[var(--radius-card)] p-6 space-y-4">
      {/* Name field */}
      <div>
        <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
          {t('{module}:form.name', 'Name')}
        </label>
        <input
          type="text"
          value={formData.name}
          onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
          className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--bg-card)] text-[var(--text-primary)] rounded-[var(--radius-input)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent-500)]"
          required
        />
      </div>

      {/* Scope selector — enum values for scoped entities */}
      <div>
        <label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
          {t('common:scope', 'Scope')}
        </label>
        <select
          value={formData.scope}
          onChange={(e) => setFormData(prev => ({ ...prev, scope: e.target.value }))}
          className="w-full px-3 py-2 border border-[var(--border-color)] rounded-[var(--radius-input)] bg-[var(--bg-card)] text-[var(--text-primary)]"
          required
        >
          <option value="Tenant">{t('common:scope.tenant', 'My Organization')}</option>
          <option value="Shared">{t('common:scope.shared', 'Shared')}</option>
          <option value="Platform">{t('common:scope.platform', 'Platform (Admin Only)')}</option>
        </select>
        <p className="text-xs text-[var(--text-secondary)] mt-1">
          {t('common:scope.help', 'Select the visibility level for this data')}
        </p>
      </div>

      {/* Actions */}
      <div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-color)]">
        <button type="button" onClick={() => navigate(-1)} className="px-4 py-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] rounded-[var(--radius-button)]">
          {t('common:actions.cancel', 'Cancel')}
        </button>
        <button type="submit" disabled={submitting} className="px-4 py-2 bg-[var(--color-accent-500)] text-white rounded-[var(--radius-button)] disabled:opacity-50">
          {submitting ? t('common:actions.saving', 'Saving...') : t('common:actions.save', 'Save')}
        </button>
      </div>
    </form>
  );
}
```

### Scope Indicator in List Views

Display a visual indicator/badge on each row showing the entity scope:

```tsx
import { useTranslation } from 'react-i18next';

// ScopeBadge component for reuse
interface ScopeBadgeProps {
  tenantId?: string | null;      // For optional entities: null = shared, value = tenant-specific
  scope?: string;                 // For scoped entities: 'Tenant' | 'Shared' | 'Platform'
}

export function ScopeBadge({ tenantId, scope }: ScopeBadgeProps) {
  const { t } = useTranslation(['common']);

  // Optional entity scope
  if (tenantId !== undefined) {
    const isTenant = Boolean(tenantId);
    return (
      <span
        className={`px-2 py-1 rounded-full text-xs font-semibold ${
          isTenant
            ? 'bg-[var(--bg-accent-light)] text-[var(--color-accent-600)]'
            : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
        }`}
      >
        {isTenant
          ? t('common:scope.tenant', 'Tenant')
          : t('common:scope.shared', 'Shared')}
      </span>
    );
  }

  // Scoped entity scope
  if (scope) {
    const scopeStyles: Record<string, { bg: string; text: string }> = {
      Tenant: {
        bg: 'bg-[var(--bg-accent-light)]',
        text: 'text-[var(--color-accent-600)]',
      },
      Shared: {
        bg: 'bg-[var(--bg-secondary)]',
        text: 'text-[var(--text-secondary)]',
      },
      Platform: {
        bg: 'bg-[var(--bg-warning-light)]',
        text: 'text-[var(--color-warning-600)]',
      },
    };

    const style = scopeStyles[scope] || scopeStyles.Tenant;
    const scopeLabel = {
      Tenant: t('common:scope.tenant', 'Organization'),
      Shared: t('common:scope.shared', 'Shared'),
      Platform: t('common:scope.platform', 'Platform'),
    }[scope] || scope;

    return (
      <span className={`px-2 py-1 rounded-full text-xs font-semibold ${style.bg} ${style.text}`}>
        {scopeLabel}
      </span>
    );
  }

  return null;
}
```

### Using ScopeBadge in DataTable Columns

```tsx
// In the list page, add a scope column
const columns = [
  { key: 'name', label: t('{module}:columns.name', 'Name') },
  { key: 'code', label: t('{module}:columns.code', 'Code') },
  {
    key: 'scope',
    label: t('common:scope', 'Scope'),
    render: (row) => (
      // For optional entities: show based on tenantId
      <ScopeBadge tenantId={row.tenantId} />
      // OR for scoped entities: show based on scope field
      // <ScopeBadge scope={row.scope} />
    ),
  },
  { key: 'actions', label: t('{module}:columns.actions', 'Actions') },
];

return (
  <DataTable
    columns={columns}
    data={data}
    searchable
    pagination={{ pageSize: 10 }}
    onRowClick={(row) => navigate(`${row.id}`)}
  />
);
```

### I18n Keys for Scope UI

Add these keys to `src/i18n/locales/*/common.json`:

```json
{
  "scope": "Scope",
  "scope.tenant": "My Organization",
  "scope.tenant.hint": "This data will only be visible to your organization",
  "scope.shared": "Shared (All Organizations)",
  "scope.shared.hint": "This data will be accessible to all organizations",
  "scope.platform": "Platform (Admin Only)",
  "scope.help": "Select the visibility level for this data"
}
```

And in the module-specific translation files (e.g., `employees.json`):

```json
{
  "form": {
    "scope": "Scope",
    "scopeHint": "Choose who can see this data"
  }
}
```

### Rules

- Provide scope controls in create forms for optional/scoped entities
- Show scope indicator badges in list views
- Use `ScopeBadge` component for consistency across modules
- Do not let users create shared entities without explicit choice
- Do not hide scope controls — scope is a business-critical property
- Include scope-related translation keys in i18n files (all 4 languages)

---

## 8. Frontend Form Testing

> **ALL form pages MUST have tests.** Forms are critical user interaction points and MUST be verified.

### Required Test Coverage per Form Page

| Test category | What to verify | Tool |
|---------------|---------------|------|
| Rendering | Form renders with all expected fields | Vitest + React Testing Library |
| Validation | Required fields show errors on empty submit | Vitest + React Testing Library |
| Submission | Successful submit calls API and navigates back | Vitest + MSW (mock API) |
| Pre-fill (edit) | Edit form loads entity data into fields | Vitest + React Testing Library |
| Navigation | Back button calls `navigate(-1)` | Vitest + React Testing Library |
| Error handling | API error displays error message | Vitest + MSW |

### Test File Convention

```
src/pages/{App}/{Module}/
├── EntityCreatePage.tsx
├── EntityCreatePage.test.tsx    ← MANDATORY
├── EntityEditPage.tsx
├── EntityEditPage.test.tsx      ← MANDATORY
├── EntityListPage.tsx
└── EntityDetailPage.tsx
```

### Create Page Test Template

```tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { describe, it, expect, vi } from 'vitest';
import { EntityCreatePage } from './EntityCreatePage';

// Mock API
vi.mock('@/services/api/apiClient');
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => ({
  ...(await vi.importActual('react-router-dom')),
  useNavigate: () => mockNavigate,
}));

describe('EntityCreatePage', () => {
  it('renders the create form with all fields', () => {
    render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
    expect(screen.getByRole('textbox', { name: /name/i })).toBeInTheDocument();
    // Verify all expected form fields
  });

  it('shows validation errors on empty submit', async () => {
    render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
    await userEvent.click(screen.getByRole('button', { name: /save|create/i }));
    await waitFor(() => {
      expect(screen.getByText(/required/i)).toBeInTheDocument();
    });
  });

  it('submits form and navigates back on success', async () => {
    render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
    await userEvent.type(screen.getByRole('textbox', { name: /name/i }), 'Test');
    await userEvent.click(screen.getByRole('button', { name: /save|create/i }));
    await waitFor(() => {
      expect(mockNavigate).toHaveBeenCalledWith(-1);
    });
  });

  it('navigates back on cancel/back button', async () => {
    render(<MemoryRouter><EntityCreatePage /></MemoryRouter>);
    await userEvent.click(screen.getByRole('button', { name: /back|cancel/i }));
    expect(mockNavigate).toHaveBeenCalledWith(-1);
  });
});
```

### Edit Page Test Template

```tsx
describe('EntityEditPage', () => {
  it('loads entity data and pre-fills the form', async () => {
    render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
    await waitFor(() => {
      expect(screen.getByDisplayValue('Existing Name')).toBeInTheDocument();
    });
  });

  it('submits updated data and navigates back', async () => {
    render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
    await waitFor(() => screen.getByDisplayValue('Existing Name'));
    await userEvent.clear(screen.getByRole('textbox', { name: /name/i }));
    await userEvent.type(screen.getByRole('textbox', { name: /name/i }), 'Updated');
    await userEvent.click(screen.getByRole('button', { name: /save/i }));
    await waitFor(() => {
      expect(mockNavigate).toHaveBeenCalledWith(-1);
    });
  });

  it('displays error when API call fails', async () => {
    // Mock API to reject
    render(<MemoryRouter initialEntries={['/entities/123/edit']}><EntityEditPage /></MemoryRouter>);
    // ... trigger submit with mocked failure
    await waitFor(() => {
      expect(screen.getByText(/failed/i)).toBeInTheDocument();
    });
  });
});
```

### Rules

- Include a companion `EntityCreatePage.test.tsx` for every `EntityCreatePage.tsx`
- Include a companion `EntityEditPage.test.tsx` for every `EntityEditPage.tsx`
- Cover in tests: rendering, validation, submit success, submit error, navigation
- Use `@testing-library/react` + `@testing-library/user-event` (NEVER enzyme)
- Mock API with `vi.mock()` or MSW — NEVER make real API calls in tests
- Test files live next to their component (co-located, NOT in a separate `__tests__/` folder)

---

## 9. Frontend Compliance Gates (5 Mandatory Checks)

> **Run these checks before any frontend commit.** All 5 gates MUST pass.

### Gate 1: CSS Variables (Theme System)

```bash
ALL_PAGES=$(find src/pages/ src/components/ -name "*.tsx" 2>/dev/null | grep -v node_modules | grep -v "\.test\.")
if [ -n "$ALL_PAGES" ]; then
  HARDCODED=$(grep -Pn '(bg|text|border)-(?!\[)(red|blue|green|gray|white|black|slate|zinc|neutral|stone)-' $ALL_PAGES 2>/dev/null)
  if [ -n "$HARDCODED" ]; then
    echo "FAIL: Hardcoded Tailwind colors found — must use CSS variables (see smartstack-frontend.md section 4)"
    echo "$HARDCODED"
  else
    echo "PASS: CSS variables"
  fi
fi
```

### Gate 2: Forms as Pages (ZERO Modals/Drawers)

```bash
PAGE_FILES=$(find src/pages/ -name "*.tsx" 2>/dev/null)
if [ -n "$PAGE_FILES" ]; then
  FAIL=false
  MODAL_IMPORTS=$(grep -Pn "import.*(?:Modal|Dialog|Drawer|Popup|Sheet|SlideOver|Overlay)" $PAGE_FILES 2>/dev/null)
  if [ -n "$MODAL_IMPORTS" ]; then
    echo "FAIL: Modal/Dialog/Drawer imports — forms MUST be full pages (see smartstack-frontend.md section 3b)"
    echo "$MODAL_IMPORTS"
    FAIL=true
  fi
  MODAL_STATE=$(grep -Pn "useState.*(?:isOpen|showModal|showDialog|showCreate|showEdit|showForm|isCreating|isEditing|showDrawer|showPanel|showSlideOver|selectedEntity|editingEntity)" $PAGE_FILES 2>/dev/null)
  if [ -n "$MODAL_STATE" ]; then
    echo "FAIL: Inline form state detected — forms MUST be separate pages (see smartstack-frontend.md section 3b)"
    echo "$MODAL_STATE"
    FAIL=true
  fi
  if [ "$FAIL" = false ]; then echo "PASS: No modals/drawers"; fi
fi
```

### Gate 3: I18n File Structure

```bash
if [ ! -d "src/i18n/locales" ]; then
  echo "FAIL: Missing src/i18n/locales/ directory"
else
  for LANG in fr en it de; do
    JSON_FILES=$(find "src/i18n/locales/$LANG" -name "*.json" 2>/dev/null | wc -l)
    if [ "$JSON_FILES" -eq 0 ]; then
      echo "FAIL: No JSON files in src/i18n/locales/$LANG/"
    else
      echo "PASS: $LANG ($JSON_FILES files)"
    fi
  done
fi
```

### Gate 4: Lazy Loading

```bash
APP_TSX=$(find src/ -name "App.tsx" -not -path "*/node_modules/*" 2>/dev/null | head -1)
ROUTE_FILES=$(find src/routes/ -name "*.tsx" -o -name "*.ts" 2>/dev/null)
if [ -n "$APP_TSX" ]; then
  STATIC_IMPORTS=$(grep -Pn "^import .+ from '@/pages/" "$APP_TSX" $ROUTE_FILES 2>/dev/null)
  if [ -n "$STATIC_IMPORTS" ]; then
    echo "FAIL: Static page imports — MUST use React.lazy() (see smartstack-frontend.md section 1)"
    echo "$STATIC_IMPORTS"
  else
    echo "PASS: Lazy loading"
  fi
fi
```

### Gate 5: useTranslation in Pages

```bash
PAGE_FILES=$(find src/pages/ -name "*.tsx" 2>/dev/null | grep -v "\.test\." | grep -v node_modules)
if [ -n "$PAGE_FILES" ]; then
  TOTAL=$(echo "$PAGE_FILES" | wc -l)
  WITH_I18N=$(grep -l "useTranslation" $PAGE_FILES 2>/dev/null | wc -l)
  if [ "$WITH_I18N" -eq 0 ]; then
    echo "FAIL: No pages use useTranslation — all text must be translated (see smartstack-frontend.md section 2)"
  else
    echo "PASS: $WITH_I18N/$TOTAL pages use useTranslation"
  fi
fi
```

### Gate 6: SmartStack Components (no raw HTML tables)

```bash
PAGE_FILES=$(find src/pages/ -name "*.tsx" 2>/dev/null | grep -v "\.test\." | grep -v node_modules)
if [ -n "$PAGE_FILES" ]; then
  FAIL=false
  RAW_TABLE=$(grep -Pn '<table[> ]|<tr[> ]|<td[> ]|<th[> ]' $PAGE_FILES 2>/dev/null)
  if [ -n "$RAW_TABLE" ]; then
    echo "BLOCKING: Raw HTML <table>/<tr>/<td> found — pages MUST use DataTable component (invoke /ui-components)"
    echo "$RAW_TABLE"
    FAIL=true
  fi
  RAW_CSS=$(grep -Pn 'style=\{?\{|style="[^"]*"' $PAGE_FILES 2>/dev/null | grep -v "className")
  if [ -n "$RAW_CSS" ]; then
    echo "BLOCKING: Inline CSS style= found — pages MUST use Tailwind + CSS variables (invoke /ui-components)"
    echo "$RAW_CSS"
    FAIL=true
  fi
  if [ "$FAIL" = false ]; then echo "PASS: SmartStack components (no raw HTML)"; fi
fi
```

> **ALL 6 gates MUST pass before frontend commit.** When delegating to `/ui-components` skill, include explicit instructions: CSS variables only, forms as full pages, i18n with namespace + fallback.
