Gotchas
Concrete bugs we hit and how we got around them. Each entry has Symptom / Cause / Fix. Covers deployment, HubSpot quirks, brand-vs-WCAG conflicts, Next.js routing, and machine-specific traps.
Deployment / Infrastructure
Caddy config poisoned by chat markdown rendering
Symptom: Caddy validate fails with "unrecognized directive: domain.com".
Cause: Copying domain names from Claude Chat (claude.ai) into a terminal pastes markdown link formatting [domain](url) instead of bare domain text. The chat UI renders bare URLs as clickable links and the copy buffer includes the markdown.
Fix: Never paste domain names from chat into config files. Either type them manually or use a script (e.g., Python open().write()) to write the file contents programmatically. If already poisoned, rewrite the entire file via script.
Deploy workflow emails failure on every push when secrets aren't set
Symptom: Every push to main triggers the Deploy to DigitalOcean workflow, which fails in 5 seconds and emails "All jobs have failed" — even though no actual deployment is meant to happen yet.
Cause: appleboy/ssh-action runs unconditionally and tries to SSH to an empty host string when DROPLET_HOST and DROPLET_SSH_KEY secrets are unset. The action treats this as a hard failure.
Fix: Workflow trigger temporarily changed to workflow_dispatch: only (manual). Re-enable push: branches:[main] once SML-001 (generate deploy SSH key + add secrets) and SML-002 (run setup-droplet.sh) are complete. The deploy.yml file has a header comment with the exact lines to restore.
Brace expansion doesn't work in all shells
Symptom: mkdir -p dir/{a,b,c} creates a single directory literally named {a,b,c} instead of three directories.
Cause: The container or script ran under /bin/sh (dash/POSIX), which does not support brace expansion. Only bash supports {a,b,c} expansion.
Fix: Use explicit mkdir -p dir/a dir/b dir/c or run under bash explicitly (bash -c 'mkdir -p dir/{a,b,c}').
Windows path length limit on archive extraction
Symptom: Extracting a tar.gz/zip on Windows fails with "filename too long" errors.
Cause: Windows default MAX_PATH is 260 characters. Nested directories like .github/workflows/ plus a deep extraction path exceed this.
Fix: Extract into a short path (e.g., C:\dash\) or extract without a wrapper folder. The zip should contain files at root level, not inside a parent directory.
HubSpot
"Closed won" maps to multiple stage IDs across pipelines
Symptom: Querying deals with dealstage = closedwon returns incomplete results — missing deals from non-default pipelines.
Cause: The string closedwon is only the stage ID for the default Sales Pipeline. Installation Pipeline 3468486848 has separate stage IDs: 4745402615 and 4745402614.
Fix: Filter on all closed-won stage IDs: IN [closedwon, 4745402615, 4745402614]. Discover pipeline-specific stage IDs by inspecting actual deal records, not the properties UI.
get_properties does not surface system-calculated properties
Symptom: hs_closed_amount_in_home_currency appears in docs but is not returned by the properties API.
Cause: System-calculated properties are not surfaced by get_properties. They exist on deal records but not in the schema endpoint.
Fix: Use amount_in_home_currency instead. Test property names against actual deal data, not the properties API.
AWST Monday boundary is Sunday UTC
Symptom: "This week" HubSpot query misses Monday deals or includes Sunday deals.
Cause: Monday 00:00 AWST = Sunday 16:00:00Z. Using naive date strings without timezone conversion shifts the boundary by 8 hours.
Fix: Always convert AWST boundaries to UTC before querying. Use GTE (>=) for start and LT (<) for end. The helper getDateRange() in /api/hubspot/tce-revenue/route.ts implements this correctly.
Design / Brand
Brand copper at 9-10px on bg-paper fails WCAG AA contrast
Symptom: Card eyebrows, kind eyebrows, "+ N more →" links and similar small captions used text-copper (#B87333) on bg-paper (#FBF8F2). axe-core flags 3.57:1 contrast — needs 4.5:1.
Cause: The brand's headline copper is calibrated for italic display accents (large text), not for 9-10px Plex Mono captions on light surfaces. WCAG doesn't care about brand tokens.
Fix: Use text-copper-2 (#8F5422 — the brand's own darker copper, originally documented as the "hover" variant) for any caption ≤14px on bg-paper. It clears 4.5:1 (≈5.1:1) and reads as the same colour family. The Card primitive's eyebrow + the brand Eyebrow primitive now default to copper-2; small captions in pages should follow.
Brand fg-4 at 8-9px fails WCAG AA on bg-paper
Symptom: Many "quiet meta" captions (calendar hour ruler, MetaStrip text, Card foots, "Promised X · Nd late" subtitles) used text-fg-4 (#7A8485). 3.62:1 on bg-paper, 3.95:1 on bg-paper-2 — both fail 4.5:1.
Cause: Brand designed fg-4 as the most-quiet meta token. At ≤9px it falls below WCAG AA.
Fix: Bulk-replaced all text-fg-4 usages with text-fg-3 (#4A5657, ≈7.3:1). Visual diff is small — captions are slightly more readable; brand intent of "quiet" preserved at the next gray step.
LogoMark monograms on copper backgrounds inherently fail at small sizes
Symptom: TQC and TCE LogoMarks (copper-2 / copper backgrounds) couldn't satisfy WCAG AA at the 28px sidebar size — neither ivory text nor forest-deep text gets 4.5:1 on copper-shade backgrounds at 12px font.
Cause: Copper hues sit in a contrast-difficult zone. No combination of ivory/forest-deep/black hits 4.5:1 on small text.
Fix (sidebar, 28px): Sub-brand row is purely decorative — section nav below it carries the navigation. Replaced lettered LogoMarks with plain colored squares (no text → no contrast issue). Wrapper marked aria-hidden="true".
Fix (entity hero, 56px): LogoMark fontWeight bumped to 700 (default). At 23.5px bold, monograms qualify as WCAG "large text" (3:1 threshold). TQC kept on default ivory text (passes 5.35:1 on copper-2); TCE explicitly uses var(--ar-forest-deep) (passes 4.23:1 on copper, large-text 3:1).
Formatters duplicated in mock-data.ts vs lib/fmt.ts (caught in compliance audit)
Symptom: fmtAUD(612480) produced "$612,480" in some places and "A$612,480.00" or similar in others, depending on which module the page imported from.
Cause: lib/mock-data.ts re-exported its own fmtAUD/fmtK/fmtSign (inherited from the design-handoff JSX) using a different implementation than lib/fmt.ts. The mock-data version was '$' + n.toLocaleString('en-AU'); the lib/fmt version used style: 'currency', currency: 'AUD' which can produce locale-dependent output. Pages mixed imports between the two modules, so identical-looking calls rendered differently.
Fix: lib/fmt.ts is the canonical formatter source. The duplicates have been removed from lib/mock-data.ts. Going forward: never import fmt* helpers from @/lib/mock-data. If a mock data file needs a formatter, the formatter goes in lib/fmt.ts and the data file imports it like everyone else.
LogoMark square=false was rendering banned rounded-full
Symptom: The non-square variant of LogoMark (round monogram pill) used borderRadius: size/2 to render a circle.
Cause: Direct port from design/handoff/shared.jsx, which predates the AGENTS.md ban list. rounded-full and equivalent inline border-radius: 50% are explicitly banned ("Sharp corners. Card radius is 2px. Buttons are 0. No capsule pills.").
Fix: Removed the square prop from components/brand/logo-mark.tsx entirely — it now always uses rounded-card. All 6 callers had square set to true anyway.
Next.js App Router blocks underscore-prefixed segments
Symptom: app/_brand/page.tsx doesn't render at /_brand — 404.
Cause: App Router treats any directory starting with _ as a private folder (not routable). This is documented Next.js behavior and there is no flag to disable it.
Fix: The brand spec called for /_brand as the storybook-style demo route. We use app/brand-demo/page.tsx → /brand-demo instead. If you must keep the underscore, put the demo behind a route group (app/(demo)/brand/page.tsx → /brand) — but the prefix is gone either way.
Chrome window resize doesn't shrink viewport on HiDPI hosts
Symptom: mcp__Claude_in_Chrome__resize_window reports success, but window.innerWidth reports the original (e.g. 2560px) so lg: Tailwind breakpoints stay active and screenshots show the desktop layout instead of the requested 390px mobile view.
Cause: On Aidan's primary monitor (4K + Windows scaling) the Chrome OS window is clamped against the screen and DPI translation. The reported "390×844" is the OS window size, not the rendered viewport.
Fix: For mobile-breakpoint visual review, either run dev server on a separate machine with a smaller display, use Chrome DevTools' Device Toolbar manually, or trust the Tailwind responsive utilities and verify via the generated CSS. Programmatic mobile screenshots from this machine are not reliable.
Claude Design used "Extensa" as the project name
Symptom: Design handoff files reference "Extensa" throughout — component names, comments, CSS class names.
Cause: Claude Design's session named the brand "Extensa" based on a separate branding project. The dashboard project is "AJ Dashboard" — the design system (forest/copper/Newsreader) is correct, but the name is not.
Fix: The design handoff files in design/handoff/ keep the Extensa naming as-is (they're reference material). Production code uses "AJ Dashboard". The ExtensaMark component name can stay or be renamed to DashboardMark — either is fine as long as it's consistent.
Projects tab
Projects tab: real-time in dev, stale-up-to-15-min in production
In Phase 6a, the projects detail view reads the dashboard's own docs/ folder via node:fs at request time, so changes appear immediately in dev. In Phase 6b this is replaced by Supabase queries populated by a 15-minute pg_cron Edge Function sync. Behaviour switches from real-time to "stale up to 15 minutes." If a doc edit needs to surface immediately for a demo or screenshot, manually invoke the sync-project-docs function via the Supabase dashboard.
Inline-edit inside a card-wide <Link> — intercept the Link's onClick, don't swap the wrapper
Symptom: Project name edits never reached Supabase. Pressing Enter to commit landed the browser at /projects/[slug] instead. Optimistic UI flashed the new name then the page navigated away. e.preventDefault() + e.stopPropagation() in the input's onKeyDown didn't help; neither did a capture-phase document.addEventListener('click', …, true) listener.
Cause (twofold):
- Clicks and keyboard activation inside a Next.js
<Link>bubble up to the anchor and trigger client-side navigation. React synthetic-eventstopPropagationalone wasn't reliably interrupting that path in this stack (Next 15 + React 19). - The first attempted fix — swapping the wrapper element between
<Link>and<div>based on edit state — made things worse. When the wrapper element type changes, React unmounts the entire subtree and remounts it, soEditableProjectName's internaleditingstate resets tofalsethe instant the parent re-renders. The pencil click flipped editing → true → false in the same tick, and the input never appeared. Fix: Always render the<Link>. Pass anonClickthat callse.preventDefault()whenever aneditingRefsays we're in edit mode. The ref is updated by anonEditingChangecallback fired fromEditableProjectName'suseEffecton edit-state changes; a siblingsetEditingTickforces a re-render so the newonClickclosure reflects the current ref. No wrapper-element change, no subtree remount, no navigation while editing. Seecomponents/projects/project-card.tsxandcomponents/projects/editable-project-name.tsx. Going forward: when conditionally disabling a wrapping interactive element, mutate its behaviour (handlers, attributes) — don't change its element type.
Missing env var on the droplet brought down the whole /projects route — lazy-init the client
Symptom: First deploy after MED-007 (inline edit) shipped to production: dashboard.finchmax.com/projects returned Application error: a server-side exception has occurred while loading dashboard.finchmax.com (see the server logs for more information). Digest: 1693179665. The page was completely dark — including read-only browsing.
Cause: lib/supabase/server.ts initialised supabasePersonalAdmin at module load with createClient(url, process.env.SUPABASE_PERSONAL_SERVICE_ROLE_KEY!). The droplet's .env.local was generated from scripts/setup-droplet.sh before that env var existed (the var was added in the same PR as the inline-edit feature), so the key was undefined on the droplet. @supabase/supabase-js throws Error: supabaseKey is required. when called with undefined, and because the throw happened at module evaluation, every request that touched the server-actions bundle for /projects crashed before rendering. journalctl showed the real error once we got SSH in; the digest in the browser obscured it.
Fix: Two layers. (1) Operational: add SUPABASE_PERSONAL_SERVICE_ROLE_KEY to the droplet's /var/www/aj-dashboard/.env.local, systemctl restart aj-dashboard. Page came back immediately. (2) Durable: lib/supabase/server.ts now exposes getSupabasePersonalAdmin() as a lazy getter instead of an eagerly-initialised value. The constructor only runs on the first call (i.e. when the action actually fires), and if the env var is still missing the action returns { ok: false, error: 'SUPABASE_PERSONAL_SERVICE_ROLE_KEY is not set …' } to the UI rather than crashing the route. The /projects read-only view keeps working even on a misconfigured host. scripts/setup-droplet.sh was also patched so fresh installs include the var in the template. Going forward: any required server-side env var should be accessed through a lazy getter that throws a named error, not via process.env.X! at module top level.
permission denied for table projects (42501) — an RLS SELECT policy is NOT a table GRANT
Symptom: Production /projects (on Vercel) threw Error: getProjects: permission denied for table projects. The read ran fine locally for writes but every anon read 401'd.
Cause: 002_projects.sql enabled RLS and created CREATE POLICY "Anon read projects" ... TO anon, authenticated USING (true) — but never issued GRANT SELECT ON projects TO anon, authenticated. RLS policies gate which rows a role may see; they do not confer the table-level SELECT privilege. PostgREST checks the GRANT first and rejects with Postgres 42501 before RLS is ever evaluated, so the permissive policy is moot. Supabase's default privileges did not auto-grant anon on this table in this org. Confirmed by probing PostgREST directly: anon key → 401 {"code":"42501","message":"permission denied for table projects"}, service_role key → 200 with rows. (The service-role JWT was valid — it decodes to role=service_role — so the key was never the problem; the client was.)
Fix: lib/projects/fetch.ts now reads via getSupabasePersonalAdmin() (service-role) instead of the anon supabasePersonal. Service-role bypasses both GRANTs and RLS. Safe here because both /projects routes are server components — the key never ships to the browser. The alternative fix (add GRANT SELECT ... TO anon to the migration and re-run it) was not taken: this is a single-operator, server-rendered, auth-gated app, so there's no value in an anon read path. Going forward: when a Supabase table 401s with 42501 despite a permissive RLS policy, check information_schema.role_table_grants — you're missing a GRANT, not a policy.
Eager top-level createClient in lib/supabase/server.ts is a module-load landmine
Symptom: After switching lib/projects/fetch.ts to import getSupabasePersonalAdmin from lib/supabase/server.ts, /projects 500'd locally with Error: supabaseUrl is required originating at the export const supabaseBC = createClient(...) line — not at any getProjects call.
Cause: server.ts initialised supabaseBC and supabaseSM eagerly at module load with createClient(process.env.SUPABASE_BC_URL!, ...). Importing anything from that module (e.g. the Personal getter) evaluates the whole module, so those eager constructors run even on a host that only has Personal env. The local .env.local is Personal-only, so the BC constructor threw at import time — crashing the route bundle before the careful lazy getSupabasePersonalAdmin() / try-catch guards could do their job. This is the same root cause as the 2026-05-23 incident (below), which only lazified the Personal client and left BC/SM eager.
Fix: Converted supabaseBC/supabaseSM to lazy getters getSupabaseBC()/getSupabaseSM() matching the Personal one. Neither had any consumers yet, so nothing else changed. Going forward: every service-role client in server.ts must be a lazy getter — never a top-level createClient with process.env.X!. One eager client makes the entire module unimportable on any host missing that one client's env.
Projects detail route is bundle-heavy
/projects/[slug] ships react-markdown plus remark-gfm, rehype-raw, and rehype-sanitize to the client because the renderer is a client component. First Load JS is around 205 kB. Acceptable for v1. If performance becomes a concern, two options: render markdown server-side and ship plain HTML, or dynamic-import the renderer so it loads on doc selection rather than route entry.