Conventions for TypeScript code in src/ and tests/. Enforced by biome where mechanical, by review where judgment-call.
// Avoid
for (let i = 0; i < items.length; i++) { ... }
for (const item of items) { ... }
// Prefer
items.map(item => …)
items.filter(item => …)
items.reduce((acc, item) => …, initial)
items.find(item => …)
items.flatMap(item => …)Exceptions: when traversal needs to mutate an external accumulator with branching control flow that doesn't compose cleanly with reduce, a for…of loop is fine. Prefer the functional form when both work.
// Avoid — `string` collapses with `'arrow'` and breaks narrowing
type Drawing = { kind: 'arrow' | string; ... }
// Prefer — discriminator is a closed literal union; open detail goes elsewhere
type Drawing =
| { kind: 'arrow'; from: [number, number]; to: [number, number] }
| { kind: 'geo'; geo: 'rectangle' | 'ellipse' | string; bbox: [number, number, number, number]; ... }any opts out of typechecking entirely. unknown forces narrowing at the use site, which catches drift when a third-party schema changes shape.
// Avoid
function getStore(snapshot: any): Record<string, any> | null { ... }
// Prefer
function getStore(snapshot: unknown): Record<string, unknown> | null {
if (!snapshot || typeof snapshot !== 'object') return null;
const s = snapshot as { document?: { store?: unknown }; store?: unknown };
if (s.document?.store && typeof s.document.store === 'object') {
return s.document.store as Record<string, unknown>;
}
...
}When a legitimate any is necessary (untyped third-party schema, non-standard DOM API), document it inline:
// biome-ignore lint/suspicious/noExplicitAny: caretRangeFromPoint is non-standard but supported in headless Chromium
const range = (document as any).caretRangeFromPoint?.(x, y);// Avoid
<div style={{ color: '#5b6cff', borderRadius: '8px', padding: '14px 28px' }}>
// Prefer
<div style={{
color: 'var(--accent)',
borderRadius: 'var(--radius-md)',
padding: 'var(--space-sm) var(--space-md)',
}}>If the value isn't a token, the token is missing. Add it to src/styles/tokens.css first, then use it.
export async function GET(req: Request, ctx: { params: Promise<{ id: string }> }) {
const ident = await identify(req);
if (!ident) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
// …
}identify is the single source of truth — see docs/api/auth.md. Never reimplement the cookie / Bearer parsing locally.
The only routes that skip this check are the three documented public surfaces (GET /api/health, POST /api/auth/login, POST /api/auth/setup) — see docs/api/INDEX.md. New public surfaces must be added to that table and carry an inline comment explaining why no identity is required.
return NextResponse.json({ error: 'invalid_body' }, { status: 400 });
return NextResponse.json({ error: 'patch_conflict', file: 'index.html' }, { status: 409 });The error field is the machine-readable identifier in snake_case. Optional fields carry context. Clients match on error, never on a human-readable message.
Services in src/lib/<surface>/service.ts are the business-logic layer. They:
- Take an input object (named keys, no positional args)
- Return the persisted record(s) on success
- Return
nullfor "not found" cases (route handler maps to 404) - Throw on programmer errors (invariants violated, unreachable branches)
export async function updateAnnotationTldraw(id: string, snapshot: unknown) {
const annotation = await prisma.annotation.findUnique({ where: { id } });
if (!annotation) return null; // → route returns 404
// … happy path writes the file and returns the row
return annotation;
}For typed errors, use a class that the route can instanceof-check:
export class DiffApplyError extends Error {
constructor(public readonly reason: 'conflict' | 'malformed') {
super(`diff_apply_failed:${reason}`);
this.name = 'DiffApplyError';
}
}- Routes under
src/app/api/<surface>/[…]/route.tsfollow Next.js App Router conventions (route.ts, dynamic segments in brackets) - Components live under
src/components/<ComponentName>/<ComponentName>.tsx— the folder hosts the component file, optional CSS, and tests - Lib helpers under
src/lib/<surface>/<name>.ts— small focused files, one responsibility per file - Tests under
tests/<unit|integration>/<surface>/<name>.test.tsmirror the file under test where practical
Default to writing none. Add a comment only when the why is non-obvious — a hidden constraint, a workaround for a specific bug, a choice that would surprise a reader. Don't comment what the code already says.
// Avoid
const tldrawAbs = path.join(env().DATA_DIR, annotation.tldrawPath); // build absolute path
// Prefer (comment carries non-obvious intent)
// Invalidate intent sidecar BEFORE writing the new tldraw — readers that
// see the new mtime should never get a stale intent.json with the old key.
deleteIntentCache(annDir);The tooltip system is a single <div popover="hint" id="markup-tooltip"> rendered once in src/app/layout.tsx via TooltipPortal (see src/components/Tooltip/TooltipPortal.tsx). A capture-phase document listener watches for mouseenter / focusin on [data-tooltip] triggers, copies the text into the popover, positions it against the trigger's getBoundingClientRect, and calls showPopover(). popover="hint" paints the element in the browser's top-layer — position: fixed against the viewport — so the tooltip escapes every overflow ancestor and stacking context. No portal bookkeeping, no z-index fight, no clipping inside .rail .list { overflow-y: auto }.
Every interactive surface that needs a hover/focus hint MUST use the data-tooltip="text" attribute. The CanvasToolbar's Zoom in / Zoom out / Fullscreen buttons are the reference; rail tooltips, kebab tooltips, comment kebab tooltips, the emoji-picker Add reaction tooltip — all route through the same TooltipPortal.
Forbidden:
title="…"attributes on buttons, links, or any interactive role (usedata-tooltipinstead; keeparia-labelfor accessibility). See the overflow-disclosure exception below.- JSX tooltip components (no
<Tooltip>…</Tooltip>wrappers, no portal-based one-off implementations). aria-describedbypaired with a visually-hidden element used as a tooltip.
Required:
<button
type="button"
data-tooltip="Keep expanded"
// For triggers near the right edge of the viewport / a container,
// anchor the bubble to the trigger's right edge so it extends left
// and never overflows.
data-tooltip-align="right"
aria-label="Keep expanded"
>
…
</button>The trigger doesn't need any positioning context — the popover lives in the top-layer and positions itself against the trigger's getBoundingClientRect. Browser support: Chrome 114+, Safari 17+, Firefox 125+.
title="…" is permitted on non-interactive display elements when the visible text is truncated by text-overflow: ellipsis. The attribute then serves as the overflow-disclosure primitive: screen readers announce the full string, mouse users get the native tooltip on hover, and keyboard users that tab to the element (when it is focusable) see the same. data-tooltip is the wrong tool here — it is for interactive hints on triggers, not for revealing clipped content.
Conditions for the exception:
- The element is not a button, link, menuitem, or any interactive role.
<h2>,<div>,<span>,<p>,<li>are typical hosts. - The same element (or a child) renders the user-supplied text that may overflow.
- The CSS on the same element produces the overflow —
text-overflow: ellipsis,-webkit-line-clamp,overflow: hiddenetc. - The
titlevalue is the same string that is being clipped.
Reference call sites (kept by exception):
src/components/ProjectCard/ProjectCard.tsx—<h2 title={project.name}>src/components/ProjectTree/TreeNode.tsx—<div title={displayLabel}>src/components/ProjectTree/ProjectTree.tsx—<div title={m.name}>on mockup-row labelssrc/app/mockups/MockupCard.tsx—<div className={styles.subtitle} title={subtitle}>
Inline comment shorthand at each site (reference, don't paraphrase):
// title= is the overflow-disclosure exception — see docs/code-style.mdAdding new exception sites: the comment above is sufficient. No ADR required; the rule is bounded and self-checking (the same string must be both clipped and used as title).
Iframe title attribute (in MockupViewer/ViewerCanvas.tsx, mockups/[id]/diff/DiffViewer.tsx) is a separate WCAG-required accessible name for the iframe — not covered by this exception, but also not forbidden by the rule because it is not a tooltip.
window.alert(), window.confirm(), and window.prompt() are banned in the codebase. They:
- Ignore every design token (background, typography, spacing, motion); the user sees a chrome of the OS instead of the product.
- Block the renderer on a synchronous event loop, which collides with automation (QA agents, Playwright) and assistive tech.
- Hijack focus on touchscreens, often firing twice or behind the page chrome.
Always use the project's useConfirm() hook from @/components/ConfirmDialog for yes/no flows. The hook returns a promise — same imperative ergonomics as confirm(), styled like the rest of the floating chrome.
// Avoid
if (!window.confirm('Delete this comment?')) return;
// Prefer
const ok = await confirm({
title: 'Delete comment',
description: 'This cannot be undone.',
confirmLabel: 'Delete',
danger: true,
});
if (!ok) return;For one-off messages (e.g. surfacing an API error), call confirm() with a single button + a cancel label of "Dismiss" — same component, no extra primitive. For multi-step flows or forms, build on top of @radix-ui/react-dialog and host the new component under src/components/<Name>Dialog/.
Every component that paints above other content — dialogs, popovers, tooltips, alerts, toasts, the rail, the canvas toolbar, the marking bar, the annotation composer, the sidebar, the command palette, every custom popup — must adopt the canonical glass treatment by composing from src/styles/glass.module.css. The utility ships two classes:
floatingSurface— setsbackground: var(--surface-glass-bg),backdrop-filter: blur(16px) saturate(140%),border: var(--surface-glass-border). Use on the floating card / panel / popover itself.floatingScrim— setsbackground: var(--scrim-glass-bg),backdrop-filter: blur(16px) saturate(140%). Use on the dimming scrim under a modal.
Required pattern in any CSS Module that paints a floating overlay:
.myDialog {
composes: floatingSurface from '../../styles/glass.module.css';
/* …layout, padding, border-radius, box-shadow, z-index… */
}Forbidden:
- Re-declaring
background,backdrop-filter, andborderinline on a floating component. The utility owns these properties; the component owns position / size / radius / shadow. - Writing
-webkit-backdrop-filtermanually next tobackdrop-filter. Lightning CSS de-dupes the pair and silently keeps only the prefixed form — Chrome then ignores it. Declare only the unprefixed property; the build tool auto-prefixes. - Using
var()insidebackdrop-filter. Lightning CSS strips that declaration too. The utility writes the value literally.
The tooltip (src/components/Tooltip/Tooltip.css) is the only floating element not composed from the utility — it's a global CSS file because popover="hint" lives at the document root. It declares the same property block inline by exception; keep its values in lockstep with the utility.
See docs/feature-catalog.md § glass-surface-standard.
Every popover (kebab menu, version chip, emoji picker, account menu, anchored option list, anything that floats next to a trigger) MUST use the usePopover hook from src/lib/popover/usePopover.ts. The hook wraps the native HTML popover API (popover="auto" + popovertarget) and pairs it with the project's position calculator (src/lib/popover/position.ts). The popover paints in the browser's top-layer — the same guarantee TooltipPortal uses — so it escapes every overflow ancestor and stacking context. The browser owns light-dismiss, ESC-to-close, and the single-active invariant.
The reference implementations are Comment.tsx (kebab), AnnotationCard.tsx (primary kebab + status group), VersionChip.tsx (nested popover for the per-row Promote/Delete menu), EmojiPicker.tsx, Topbar.tsx (account menu), and ProjectTree.tsx's TreeNodeKebab subcomponent.
Forbidden:
useState(menuOpen)+document.addEventListener('mousedown', …)outside-click effects. The browser already does this forpopover="auto".- Generic
<Dropdown>/<Menu>JSX wrappers. The hook is the primitive — there's no extra layer. position: absoluteon the popover element. The popover mustposition: fixedso the JS-writtentop/leftwin over the browser defaultinset: 0; margin: auto.
Required pattern:
const menu = usePopover<HTMLButtonElement, HTMLDivElement>('right');
return (
<>
<button
ref={menu.triggerRef}
type="button"
data-tooltip="Open menu"
aria-haspopup="menu"
{...menu.triggerProps}
>
⋮
</button>
<div {...menu.popoverProps} className={styles.menu} role="menu">
<button
role="menuitem"
onClick={() => {
menu.close();
doAction();
}}
>
Action
</button>
</div>
</>
);Required CSS:
.menu {
position: fixed;
inset: auto;
margin: 0;
display: none;
flex-direction: column;
/* …glass styling… */
}
.menu:popover-open {
display: flex;
}Nested popovers stack natively: opening one popover from inside another keeps the parent open via the HTML popover spec's ancestor relationship (VersionChip's per-row kebab demonstrates this). Each row that needs its own popover gets its own usePopover call — extract a subcomponent so the hook isn't called inside a loop. Browser support: Chrome 114+, Safari 17+, Firefox 125+ (same baseline as data-tooltip).
Services are functions that take input and return output. Avoid classes with state — they hide control flow and break tree-shaking. The DiffApplyError class is an exception because it composes with instanceof.
Use logger from src/lib/logger.ts — structured pino with named children. Don't use console.log in src/; the lint rule will allow it in scripts/ since those are one-shot tools.
import { logger } from '@/lib/logger';
const log = logger.child({ name: 'mockup-service' });
log.info({ mockupId, versionId }, 'version_created');The first argument is structured fields; the second is the human-readable message (a snake_case event name keeps logs greppable).