Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ e2e/.cleanup-ledger.json
.content/

coverage/
.claude/worktrees/
15 changes: 15 additions & 0 deletions src/layout/PublicShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PublicShell><p>hi</p></PublicShell>)
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')
})
})
9 changes: 7 additions & 2 deletions src/layout/PublicShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,10 @@ function PublicFooter() {
served as text/markdown — a visitor saw unrendered
"## Reporting a vulnerability" source. */}
<a href="/security">Security</a>
<a href="mailto:hello@instanode.dev">Contact</a>
{/* 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. */}
<a href="mailto:contact@instanode.dev">Contact</a>
</div>
</div>
</div>
Expand Down Expand Up @@ -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;
Expand Down
105 changes: 105 additions & 0 deletions src/lib/planLimits.test.ts
Original file line number Diff line number Diff line change
@@ -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<Tier, { connections: number; objectStorageMB: number }> = {
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)
})
})
99 changes: 99 additions & 0 deletions src/lib/planLimits.ts
Original file line number Diff line number Diff line change
@@ -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<Tier, PlanLimits> = {
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
}
69 changes: 69 additions & 0 deletions src/pages/MarketingPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MemoryRouter initialEntries={['/']}>
<MarketingPage />
</MemoryRouter>,
)
}

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 <h3>, not <h4> (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 <h3>.
expect(headers.length).toBeGreaterThanOrEqual(2)
for (const h of headers) {
expect(h).not.toBeNull()
expect(h!.tagName).toBe('H3')
}
// And no <h4> anywhere skips the level on the page.
expect(document.querySelector('.mkt-footer-col h4')).toBeNull()
})

it('heading levels never skip (no <hN> without an <hN-1> 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')
})
})
30 changes: 22 additions & 8 deletions src/pages/MarketingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,12 @@ export function MarketingPage() {
{/* ---------- top nav (sticky, glassmorphic) ---------- */}
<nav className="mkt-nav" aria-label="Primary">
<div className="mkt-wrap mkt-nav-inner">
<a href="/" className="mkt-brand-link" aria-label="instanode home">
{/* WCAG 2.5.3 (Label in Name): the accessible name must contain the
visible text. The brand renders "instanode.dev", so the
aria-label must include "instanode.dev" (was "instanode home",
which dropped the visible ".dev" — a label/name mismatch a
voice-control user can't target). Matches PublicShell's brand. */}
<a href="/" className="mkt-brand-link" aria-label="instanode.dev — home">
<Brand />
</a>
{/* B1-P0-1 (2026-05-20): nav links read from the shared
Expand Down Expand Up @@ -612,9 +617,11 @@ export function MarketingPage() {
product run in production + staging + development. Team is for the company
that ships every day —{' '}
{/* BugBash P3-09: the teaser said "talk to us" but had no
contact path. Link to the real address used elsewhere
(PublicShell footer Contact, support FAQ). */}
<a href="mailto:hello@instanode.dev">talk to us</a> about your needs.
contact path. 2026-06-11: standardized on contact@ — the
canonical support address used by the FAQ, cancel/downgrade
links, billing, terms, and the content repo. (sales@ stays
for Team/Enterprise lead capture only.) */}
<a href="mailto:contact@instanode.dev">talk to us</a> about your needs.
</p>
</div>

Expand Down Expand Up @@ -770,15 +777,21 @@ export function MarketingPage() {
<code>npm create</code> to a live URL.
</p>
</div>
{/* WCAG 1.3.1 / heading-order: these footer column headers were
<h4> while the page's deepest preceding heading was <h3> (the
how-it-works step cards). An <h4> with no <h3> ancestor in the
footer skips a level, breaking the document outline for screen
readers. They're <h3> now — the next level below the page's
<h2> sections, no skipped level. */}
<div className="mkt-footer-col">
<h4>Product</h4>
<h3>Product</h3>
<a href={ROUTES.pricing}>Pricing</a>
<a href={ROUTES.forAgents}>For agents</a>
<a href={ROUTES.docs}>Docs</a>
<a href={ROUTES.blog}>Blog</a>
</div>
<div className="mkt-footer-col">
<h4>Legal</h4>
<h3>Legal</h3>
{/* W12 H15: /privacy and /terms are real routes again
(stop-gap placeholder pages with a legal@ email).
/llms.txt is served from the apex by the prerender
Expand Down Expand Up @@ -1746,10 +1759,11 @@ const MKT_CSS = `
font-size: 12px;
color: var(--text);
}
.mkt-footer-col h4 {
.mkt-footer-col h3 {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-faint);
/* WCAG AA: --text-muted (5.1:1) not --text-faint (2.4:1) — read content. */
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 16px;
Expand Down
Loading
Loading