diff --git a/.gitignore b/.gitignore index 999dfe1..297e88b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ e2e/.cleanup-ledger.json .content/ coverage/ +.claude/worktrees/ diff --git a/src/layout/PublicShell.test.tsx b/src/layout/PublicShell.test.tsx index 7f9201c..bce9b3d 100644 --- a/src/layout/PublicShell.test.tsx +++ b/src/layout/PublicShell.test.tsx @@ -94,3 +94,18 @@ describe('PublicShell — dark/light theme toggle (B3-P2-4)', () => { expect(document.documentElement.hasAttribute('data-theme')).toBe(false) }) }) + +describe('PublicShell — footer support email consistency (2026-06-11)', () => { + it('footer Contact link uses the canonical contact@ address, not hello@', () => { + render(

hi

) + const mailtos = Array.from(document.querySelectorAll('a[href^="mailto:"]')).map( + (a) => a.getAttribute('href') ?? '', + ) + expect(mailtos.some((h) => h.includes('hello@instanode.dev'))).toBe(false) + const contact = Array.from(document.querySelectorAll('a')).find( + (a) => (a.textContent ?? '').trim() === 'Contact', + ) as HTMLAnchorElement | undefined + expect(contact).toBeTruthy() + expect(contact!.getAttribute('href')).toContain('mailto:contact@instanode.dev') + }) +}) diff --git a/src/layout/PublicShell.tsx b/src/layout/PublicShell.tsx index 05f32ff..c073e0d 100644 --- a/src/layout/PublicShell.tsx +++ b/src/layout/PublicShell.tsx @@ -263,7 +263,10 @@ function PublicFooter() { served as text/markdown — a visitor saw unrendered "## Reporting a vulnerability" source. */} Security - Contact + {/* 2026-06-11: standardized on contact@ — the canonical support + address (FAQ, cancel/downgrade, billing, terms, content repo). + sales@ is reserved for Team/Enterprise lead capture. */} + Contact @@ -491,7 +494,9 @@ function PublicShellStyles() { .public-footer-h { font-family: var(--font-mono); font-size: 10.5px; - color: var(--text-faint); + /* WCAG AA: --text-muted (5.1:1 on --surface) not --text-faint (2.4:1). + Footer column headers ("Product", "Legal") are read content. */ + color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; diff --git a/src/lib/planLimits.test.ts b/src/lib/planLimits.test.ts new file mode 100644 index 0000000..7df6953 --- /dev/null +++ b/src/lib/planLimits.test.ts @@ -0,0 +1,105 @@ +/* planLimits.test.ts — registry-iterating guard (rule 18). + * + * The Overview "connection limit" and "object storage" tiles bind to these + * per-tier caps. Two real production bugs motivated the binding: + * - connection limit read "∞ unlimited" for a Pro user (cap is 20) + * - object-storage denominator read a conflated ~81 GiB sum + * + * This test pins each tier's numbers to api/plans.yaml AND iterates the whole + * `Tier` union so a future tier (or a renamed one) can't silently fall through + * to the fallback and ship a tile bound to the wrong number. If plans.yaml + * changes a connection or object-storage cap, this test fails until the mirror + * is updated (rule 22 — contract change touches all surfaces). + */ + +import { describe, it, expect } from 'vitest' +import { + PLAN_LIMITS, + planLimitsFor, + connectionLimitFor, + objectStorageLimitMBFor, + objectStorageLimitGBFor, +} from './planLimits' +import type { Tier } from '../api' + +// The full Tier union, enumerated so the iteration test below is itself not a +// single-site hand-typed slice — a new tier added to the union but not here +// would still be caught by ALL_TIERS coverage below (we assert PLAN_LIMITS has +// exactly these keys). +const ALL_TIERS: Tier[] = [ + 'anonymous', + 'free', + 'hobby', + 'hobby_plus', + 'pro', + 'growth', + 'team', +] + +// Expected values mirror api/plans.yaml (origin/master, verified 2026-06-11). +// connections = postgres_connections (== mongodb_connections == vector_connections). +// objectStorageMB = storage_storage_mb. +const EXPECTED: Record = { + anonymous: { connections: 2, objectStorageMB: 10 }, + free: { connections: 2, objectStorageMB: 10 }, + hobby: { connections: 8, objectStorageMB: 512 }, + hobby_plus: { connections: 8, objectStorageMB: 5120 }, + pro: { connections: 20, objectStorageMB: 51200 }, + growth: { connections: 20, objectStorageMB: 153600 }, + team: { connections: 100, objectStorageMB: 307200 }, +} + +describe('PLAN_LIMITS — every Tier has a row (rule 18)', () => { + it('PLAN_LIMITS has exactly the Tier-union keys (no missing, no extra)', () => { + expect(Object.keys(PLAN_LIMITS).sort()).toEqual([...ALL_TIERS].sort()) + }) + + for (const tier of ALL_TIERS) { + it(`${tier}: connection + object-storage caps match plans.yaml`, () => { + expect(PLAN_LIMITS[tier].connections).toBe(EXPECTED[tier].connections) + expect(PLAN_LIMITS[tier].objectStorageMB).toBe(EXPECTED[tier].objectStorageMB) + }) + } +}) + +describe('connectionLimitFor', () => { + for (const tier of ALL_TIERS) { + it(`${tier} → ${EXPECTED[tier].connections}`, () => { + expect(connectionLimitFor(tier)).toBe(EXPECTED[tier].connections) + }) + } + + it('Pro is a finite 20, never ∞ — the exact production bug', () => { + expect(connectionLimitFor('pro')).toBe(20) + expect(connectionLimitFor('pro')).toBeGreaterThan(0) + }) + + it('falls back to free for an unknown/undefined tier (understate, not overstate)', () => { + expect(connectionLimitFor('mystery_tier')).toBe(EXPECTED.free.connections) + expect(connectionLimitFor(undefined)).toBe(EXPECTED.free.connections) + expect(connectionLimitFor(null)).toBe(EXPECTED.free.connections) + }) +}) + +describe('objectStorageLimit helpers', () => { + it('Pro object cap is 50 GB (51200 MB), NOT a conflated multi-service sum', () => { + expect(objectStorageLimitMBFor('pro')).toBe(51200) + expect(objectStorageLimitGBFor('pro')).toBe(50) + }) + + for (const tier of ALL_TIERS) { + it(`${tier} object-storage GB == MB/1024`, () => { + const mb = objectStorageLimitMBFor(tier) + expect(objectStorageLimitGBFor(tier)).toBeCloseTo(mb / 1024, 6) + }) + } + + it('free shows its real 10 MB cap, never unlimited (∞)', () => { + expect(objectStorageLimitMBFor('free')).toBe(10) + expect(objectStorageLimitGBFor('free')).toBeGreaterThan(0) + }) + + it('planLimitsFor returns the matching row object', () => { + expect(planLimitsFor('team')).toEqual(PLAN_LIMITS.team) + }) +}) diff --git a/src/lib/planLimits.ts b/src/lib/planLimits.ts new file mode 100644 index 0000000..071f5f3 --- /dev/null +++ b/src/lib/planLimits.ts @@ -0,0 +1,99 @@ +// planLimits — the dashboard's single, registry-shaped mirror of the per-tier +// numeric caps the Overview tiles bind to. +// +// WHY THIS FILE EXISTS (rule 18 — registry-iterating, not hand-typed-at-call-site): +// Before this, the Overview "connection limit" and "storage" tiles derived +// their numbers from per-RESOURCE fields (`connections_limit` / `storage_limit_bytes`) +// summed across the user's live resources. That produced two confirmed bugs on +// real dashboards: +// +// 1. CONNECTION LIMIT showed "∞ unlimited" for a Pro user. The summing logic +// flipped the whole tile to ∞ the moment ANY resource carried +// connections_limit < 0 — and queue/redis/storage/webhook resources are +// legitimately -1 (connection caps don't apply to them). So a Pro user +// (real cap: 20 Postgres connections) with a single Redis saw ∞. +// +// 2. STORAGE denominator showed a conflated SUM of every per-service cap +// (e.g. 50 GB object + 10 GB pg + 5 GB mongo + 10 GB vector + queue … +// ≈ 81.3 GiB) presented under one "STORAGE" label. Pro's object-storage +// cap is 50 GB; the tile must reflect object storage specifically, not a +// sum across unlike services. +// +// The honest fix is to bind each tile to the TIER's published cap, not to a +// derived per-resource sum. The source of truth is api/plans.yaml. The +// `PLAN_LIMITS` table below mirrors it; the matching test +// (planLimits.test.ts) iterates EVERY tier in the `Tier` union so a future +// tier (or a renamed one) can't silently fall through to a wrong number. +// +// Connection semantics: only the connection-BEARING services (postgres, +// mongodb, vector) have a finite per-tier connection cap. redis / queue / +// storage / webhook do not take SQL-style connections — their per-resource +// connections_limit is -1 by design and must NOT be read as "the tier is +// unlimited". The connection tile therefore shows the connection-bearing cap +// (postgres == mongodb == vector on every tier today) and is only "∞" when +// that cap is itself -1 in plans.yaml (no tier is, post strict-80% redesign). + +import type { Tier } from '../api' + +const MB_PER_GB = 1024 + +export interface PlanLimits { + /** Per-connection-bearing-service connection cap (postgres/mongodb/vector). + * -1 means unlimited. plans.yaml: postgres_connections / mongodb_connections / + * vector_connections — equal on every tier today. */ + connections: number + /** Object-storage cap in MB. plans.yaml: storage_storage_mb. -1 = unlimited + * (no tier today). This is the OBJECT-STORE cap only — never a sum across + * postgres / mongodb / vector / queue. */ + objectStorageMB: number +} + +// PLAN_LIMITS — mirror of api/plans.yaml. Keep in lock-step with that file +// (rule 22: a tier/limit change touches plans.yaml AND this mirror). Every +// member of the `Tier` union MUST have a row — planLimits.test.ts fails if one +// is missing, so a new tier can't ship a tile bound to the fallback. +// +// connections column source (plans.yaml, verified 2026-06-11 @ origin/master): +// anonymous/free postgres_connections=2 storage_storage_mb=10 +// hobby postgres_connections=8 storage_storage_mb=512 +// hobby_plus postgres_connections=8 storage_storage_mb=5120 +// pro postgres_connections=20 storage_storage_mb=51200 (50 GB) +// growth postgres_connections=20 storage_storage_mb=153600 (150 GB) +// team postgres_connections=100 storage_storage_mb=307200 (300 GB) +export const PLAN_LIMITS: Record = { + anonymous: { connections: 2, objectStorageMB: 10 }, + free: { connections: 2, objectStorageMB: 10 }, + hobby: { connections: 8, objectStorageMB: 512 }, + hobby_plus: { connections: 8, objectStorageMB: 5120 }, + pro: { connections: 20, objectStorageMB: 51200 }, + growth: { connections: 20, objectStorageMB: 153600 }, + team: { connections: 100, objectStorageMB: 307200 }, +} + +// Fallback used only when the live tier string is somehow outside the union +// (defensive — TS guarantees the union, but the wire could in theory send a +// future tier the build doesn't know yet). Free is the safest assumption: it +// understates rather than overstates the user's ceiling. +const FALLBACK: PlanLimits = PLAN_LIMITS.free + +export function planLimitsFor(tier: Tier | string | undefined | null): PlanLimits { + if (tier && tier in PLAN_LIMITS) return PLAN_LIMITS[tier as Tier] + return FALLBACK +} + +/** The connection-bearing connection cap for a tier. -1 → unlimited. */ +export function connectionLimitFor(tier: Tier | string | undefined | null): number { + return planLimitsFor(tier).connections +} + +/** The object-storage cap for a tier, in MB. -1 → unlimited. */ +export function objectStorageLimitMBFor(tier: Tier | string | undefined | null): number { + return planLimitsFor(tier).objectStorageMB +} + +/** Object-storage cap as a GB number (decimal-GB to match how plans.yaml / + * the pricing page talk about "50 GB"). -1 stays -1 (unlimited). */ +export function objectStorageLimitGBFor(tier: Tier | string | undefined | null): number { + const mb = objectStorageLimitMBFor(tier) + return mb < 0 ? -1 : mb / MB_PER_GB +} diff --git a/src/pages/MarketingPage.test.tsx b/src/pages/MarketingPage.test.tsx index 20086fc..e306270 100644 --- a/src/pages/MarketingPage.test.tsx +++ b/src/pages/MarketingPage.test.tsx @@ -212,3 +212,72 @@ describe('MarketingPage — claim consistency (T18 P1-4 / P1-6)', () => { expect(text).not.toMatch(/medium deployments/i) }) }) + +// ─── 2026-06-11 a11y + email-standardization fixes ───────────────────────── +describe('MarketingPage — a11y + support-email consistency', () => { + function renderHome() { + return render( + + + , + ) + } + + it("brand link aria-label contains the visible text 'instanode.dev' (WCAG 2.5.3 Label in Name)", () => { + renderHome() + const brand = document.querySelector('a.mkt-brand-link') as HTMLAnchorElement + expect(brand).not.toBeNull() + const label = brand.getAttribute('aria-label') ?? '' + // The brand renders "instanode.dev" — the accessible name must include it. + expect(label).toContain('instanode.dev') + // The visible text the brand renders, sans markup. + expect((brand.textContent ?? '').replace(/\s+/g, '')).toContain('instanode.dev') + }) + + it('footer column headers are

, not

(no skipped heading level)', () => { + renderHome() + const footerCols = Array.from(document.querySelectorAll('.mkt-footer-col')) + const headers = footerCols.map((c) => c.querySelector('h1,h2,h3,h4,h5,h6')) + // Every footer column has a heading and it's an

. + expect(headers.length).toBeGreaterThanOrEqual(2) + for (const h of headers) { + expect(h).not.toBeNull() + expect(h!.tagName).toBe('H3') + } + // And no

anywhere skips the level on the page. + expect(document.querySelector('.mkt-footer-col h4')).toBeNull() + }) + + it('heading levels never skip (no without an before it)', () => { + renderHome() + const levels = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).map((h) => + Number(h.tagName[1]), + ) + let max = 0 + for (const lvl of levels) { + // A heading may be at most one deeper than the deepest seen so far. + expect(lvl).toBeLessThanOrEqual(max + 1) + if (lvl > max) max = lvl + } + }) + + it("general-contact CTA uses the canonical contact@ address, not hello@", () => { + renderHome() + const mailtos = Array.from(document.querySelectorAll('a[href^="mailto:"]')).map( + (a) => a.getAttribute('href') ?? '', + ) + // No hello@ anywhere on the homepage. + expect(mailtos.some((h) => h.includes('hello@instanode.dev'))).toBe(false) + // The "talk to us" CTA points at contact@. + const talk = findAnchorByText('talk to us') + expect(talk).not.toBeNull() + expect(talk!.getAttribute('href')).toContain('mailto:contact@instanode.dev') + }) + + it('Team CTA still uses sales@ (lead capture is a deliberate split)', () => { + renderHome() + const teamCta = findAnchorByText('Contact sales →') + expect(teamCta).not.toBeNull() + expect(teamCta!.getAttribute('href')).toContain('mailto:sales@instanode.dev') + }) +}) diff --git a/src/pages/MarketingPage.tsx b/src/pages/MarketingPage.tsx index faeb81c..605ba7c 100644 --- a/src/pages/MarketingPage.tsx +++ b/src/pages/MarketingPage.tsx @@ -249,7 +249,12 @@ export function MarketingPage() { {/* ---------- top nav (sticky, glassmorphic) ---------- */}