Scope: Frontend assets in internal/web/ directory - TypeScript, Tailwind CSS, HTML templates
See also: ../../AGENTS.md for global standards, ../AGENTS.md for Go backend
Frontend implementation for LDAP selfservice password changer with strict accessibility compliance:
- static/: Client-side TypeScript, compiled CSS, static assets
- js/: TypeScript source files (compiled to ES modules)
- styles.css: Tailwind CSS output
- static.go: Embedded filesystem for static assets
- Icons, logos, favicons, manifest
- templates/: Go HTML templates (*.gohtml)
- templates.go: Template rendering functions
- tailwind.css: Tailwind CSS source (directives)
Note: HTTP handlers, middleware, and server setup are in main.go at the project root, not in this directory.
Key characteristics:
- WCAG 2.2 AAA: 7:1 contrast, keyboard navigation, screen reader support, adaptive density
- Ultra-strict TypeScript: All strict flags enabled, no
anytypes - Tailwind CSS 4: Utility-first, dark mode, responsive, accessible patterns
- Progressive enhancement: Works without JavaScript (forms submit via HTTP)
- Password manager friendly: Proper autocomplete attributes
Prerequisites: Bun, no version pinned — package.json sets neither packageManager nor engines, and CI installs the latest. Install it separately. Node.js only needed for tooling that doesn't run under Bun.
# From project root
bun install --frozen-lockfile # Install dependencies
# Development (watch mode)
bun run css:dev # Tailwind CSS watch
bun run js:dev # TypeScript watch
# OR
bun run dev # Concurrent: CSS + TS + Go hot-reloadNo .env needed for frontend - all config comes from Go backend
Browser targets: Modern browsers with ES module support (Chrome 90+, Firefox 88+, Safari 14+, Edge 90+)
# Build frontend assets
bun run build:assets # TypeScript + CSS (production builds)
# TypeScript
bun run js:build # Compile TS → ES modules (type-check; no minifier configured)
bun run js:dev # Watch mode with preserveWatchOutput
tsc --noEmit # Type check only (no output)
# CSS
bun run css:build # Tailwind + PostCSS → styles.css
bun run css:dev # Watch mode
# Formatting
bunx prettier --write internal/web/ # Format TS, CSS, HTML templates
bunx prettier --check internal/web/ # Check formatting (CI)No unit tests yet - TypeScript strict mode catches most errors, integration via Go tests
CI validation (.github/workflows/check.yml):
# "Check types" job
bun install
bun run js:build # TypeScript strict compilation# "Check formatting" job (separate)
bun install
bunx prettier --check .Accessibility testing:
- Keyboard navigation: Tab through all interactive elements
- Screen reader: Test with VoiceOver (macOS/iOS) or NVDA (Windows)
- Contrast: Verify 7:1 ratios with browser dev tools
- See ../../docs/accessibility.md for comprehensive guide
TypeScript Ultra-Strict (from tsconfig.json):
{
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}No any types allowed:
// ✅ Good: explicit types
function validatePassword(password: string, minLength: number): boolean {
return password.length >= minLength;
}
// ❌ Bad: any type
function validatePassword(password: any): boolean {
return password.length >= 8; // ❌ unsafe
}Prettier formatting:
- 120 char width
- 2-space indentation
- Semicolons required
- Double quotes (not single)
- Trailing comma: none
File organization:
- TypeScript source:
static/js/*.ts - Output:
static/js/*.js(minified ES modules) - CSS input:
tailwind.css(Tailwind directives) - CSS output:
static/styles.css(PostCSS processed)
Required compliance - not optional:
- All interactive elements focusable with Tab
- Visual focus indicators (4px outline, 7:1 contrast)
- Logical tab order (top to bottom, left to right)
- No keyboard traps
- Skip links where needed
- Semantic HTML:
<button>,<input>,<label>, not<div onclick> - ARIA labels on icon-only buttons:
aria-label="Submit" - Error messages:
aria-describedbylinking to error text - Live regions for dynamic content:
aria-live="polite" - Form field associations:
<label for="id">+<input id="id">
- Text: 7:1 contrast ratio (AAA)
- Large text (18pt+): 4.5:1 minimum
- Focus indicators: 3:1 against adjacent colors
- Dark mode: same contrast requirements
- Never rely on color alone (use icons, text, patterns)
- Responsive: layout adapts to viewport size
- Text zoom: 200% without horizontal scroll
- Adaptive density: spacing adjusts for user preferences
- Touch targets: 44×44 CSS pixels minimum (mobile)
✅ Good: Accessible button
<button type="submit" class="btn-primary focus:ring-4 focus:ring-blue-300" aria-label="Submit password change">
<svg aria-hidden="true">...</svg>
Change Password
</button>❌ Bad: Inaccessible div-button
<div onclick="submit()" class="button">❌ not keyboard accessible Submit</div>✅ Good: Form with error handling
<form>
<label for="password">New Password</label>
<input
id="password"
type="password"
aria-describedby="password-error"
aria-invalid="true"
autocomplete="new-password"
/>
<div id="password-error" role="alert">Password must be at least 8 characters</div>
</form>❌ Bad: Form without associations
<form>
<div>Password</div>
❌ not a label, no association <input type="password" /> ❌ no autocomplete, no error linkage
<div style="color: red">Error</div>
❌ no role="alert", only color
</form>Use utility classes, not custom CSS:
✅ Good: Utility classes
<button
class="rounded-lg bg-blue-600 px-4 py-2 font-semibold text-white hover:bg-blue-700 focus:ring-4 focus:ring-blue-300"
>
Submit
</button>❌ Bad: Custom CSS
<button class="custom-button">Submit</button>
<style>
.custom-button {
background: blue;
} /* ❌ Use Tailwind utilities */
</style>Dark mode support:
<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">Content</div>Responsive design:
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Responsive grid: 1 col mobile, 2 tablet, 3 desktop -->
</div>Focus states (required):
<button class="focus:ring-4 focus:ring-blue-300 focus:outline-none">
<!-- 4px focus ring, 7:1 contrast -->
</button>Strict null checking:
// ✅ Good: handle nulls explicitly
function getElement(id: string): HTMLElement | null {
return document.getElementById(id);
}
const el = getElement("password");
if (el) {
// ✅ null check
el.textContent = "Hello";
}
// ❌ Bad: assume non-null
const el = getElement("password");
el.textContent = "Hello"; // ❌ may crash if nullType guards:
// ✅ Good: type guard for forms
function isHTMLFormElement(element: Element): element is HTMLFormElement {
return element instanceof HTMLFormElement;
}
const form = document.querySelector("form");
if (form && isHTMLFormElement(form)) {
form.addEventListener("submit", handleSubmit);
}No unsafe array access:
// ✅ Good: check array bounds
const items = ["a", "b", "c"];
const first = items[0]; // string | undefined (noUncheckedIndexedAccess)
if (first) {
console.log(first.toUpperCase());
}
// ❌ Bad: unsafe access
console.log(items[0].toUpperCase()); // ❌ may crash if empty arrayThreat model: frontend assets are served to unauthenticated users and rendered in their browser. Treat everything that reaches the DOM as potentially attacker-controlled.
- No inline scripts. Content-Security-Policy rejects them. All JS lives in
static/js/*.ts→ compiled to ES modules and loaded via<script type="module" src=...>. - Escape at the template boundary. Go templates auto-escape via
html/template; never emit raw HTML into a template. For dynamic values that must include markup, sanitize server-side before rendering. - No
innerHTMLfor user-controlled data. UsetextContentor build DOM nodes withcreateElement. OnlyinnerHTMLa known-static string. - No
eval/Function(...)/setTimeout(stringArg). Treat these as banned — they bypass CSP and taint the whole page. - Autocomplete hygiene. New-password fields MUST set
autocomplete="new-password"; current-password fields useautocomplete="current-password". Wrong or missing autocomplete confuses password managers and can cause them to fill wrong values. - Password-manager-friendly forms. Every password input sits inside a
<form>with proper<label>. Don't hide real<input type="password">behind a fake div — managers won't detect it. - No PII in client logs. Never
console.logpasswords, tokens, usernames, or email addresses. Strip diagnostic logs before committing. - No third-party scripts. Keep the CSP strict; no CDN-hosted libraries, no analytics, no remote fonts. All assets ship from the same origin via Go's embedded FS.
- Focus trap on modals. Any dialog must trap focus while open (for security + a11y). Escape must close and restore focus.
- Subresource integrity. If a local asset ever must reference an external URL (shouldn't happen here), pin with SRI hashes.
- Tailwind classes are static. Never template-compose class names from user data — JIT won't pick them up and the attacker could produce unexpected styling. Always full static strings.
Verify: browser DevTools → Network → confirm CSP header default-src 'self' (or stricter). Console → no CSP violations.
Before committing frontend code:
- Run
bun run js:build(TypeScript strict check) - Run
bunx prettier --write internal/web/ - Verify keyboard navigation works
- Test with screen reader (VoiceOver/NVDA)
- Check contrast ratios (7:1 for text)
- Test dark mode
- Verify password manager autofill works
- No console errors in browser
- Test on mobile viewport (responsive)
Accessibility checklist:
- All interactive elements keyboard accessible
- Focus indicators visible (4px outline, 7:1 contrast)
- ARIA labels on icon-only buttons
- Form fields properly labeled
- Error messages linked with aria-describedby
- No color-only information conveyance
- Touch targets ≥44×44 CSS pixels (mobile)
Performance checklist:
- TypeScript compiled cleanly (
bun run js:build; no minification step is configured today — if added, reference it here) - CSS optimized (Lightning CSS via @tailwindcss/postcss)
- No unused Tailwind classes (purged automatically)
- No console.log in production code
✅ Good: Type-safe DOM access
function setupPasswordToggle(): void {
const toggle = document.getElementById("toggle-password");
const input = document.getElementById("password");
if (!toggle || !(input instanceof HTMLInputElement)) {
return; // Guard against missing elements
}
toggle.addEventListener("click", () => {
input.type = input.type === "password" ? "text" : "password";
});
}❌ Bad: Unsafe DOM access
function setupPasswordToggle() {
const toggle = document.getElementById("toggle-password")!; // ❌ non-null assertion
const input = document.getElementById("password") as any; // ❌ any type
toggle.addEventListener("click", () => {
input.type = input.type === "password" ? "text" : "password"; // ❌ may crash
});
}✅ Good: Accessible form validation
function showError(input: HTMLInputElement, message: string): void {
const errorId = `${input.id}-error`;
let errorEl = document.getElementById(errorId);
if (!errorEl) {
errorEl = document.createElement("div");
errorEl.id = errorId;
errorEl.setAttribute("role", "alert");
errorEl.className = "text-red-600 dark:text-red-400 text-sm mt-1";
input.parentElement?.appendChild(errorEl);
}
errorEl.textContent = message;
input.setAttribute("aria-invalid", "true");
input.setAttribute("aria-describedby", errorId);
}❌ Bad: Inaccessible validation
function showError(input: any, message: string) {
// ❌ any type
input.style.borderColor = "red"; // ❌ color only, no text
alert(message); // ❌ blocks UI, not persistent
}TypeScript issues:
- Type errors: Check
tsconfig.jsonflags, use proper types (noany) - Null errors: Add null checks or type guards
- Module errors: Ensure ES module syntax (
import/export) - Build errors:
bun install --frozen-lockfileto refresh dependencies
CSS issues:
- Styles not applying: Check Tailwind purge config, rebuild with
bun run css:build - Dark mode broken: Use
dark:prefix on utilities - Responsive broken: Use
md:,lg:breakpoint prefixes - Custom classes: Don't - use Tailwind utilities instead
Accessibility issues:
- Keyboard nav broken: Check tab order, focus indicators
- Screen reader confusion: Verify ARIA labels, semantic HTML
- Contrast failure: Use darker colors, test with dev tools
- See: ../../docs/accessibility.md
Browser dev tools:
- Accessibility tab: Check ARIA, contrast, structure
- Lighthouse: Run accessibility audit (aim for 100 score)
- Console: No errors in production code
Manual testing required (no automated frontend tests yet):
- Visual testing: Check all pages in light/dark mode
- Keyboard testing: Tab through all interactive elements
- Screen reader testing: Use VoiceOver (Cmd+F5) or NVDA
- Responsive testing: Test mobile, tablet, desktop viewports
- Browser testing: Chrome, Firefox, Safari, Edge
- Password manager: Test autofill with 1Password, LastPass, etc.
Accessibility testing tools:
- Browser dev tools Lighthouse
- axe DevTools extension
- WAVE browser extension
- Manual keyboard/screen reader testing (required)
Integration testing: Go backend tests exercise full request/response flow including frontend templates