Skip to content

Commit db96d4c

Browse files
fix(dashboard): bind Overview tiles to per-tier caps + public-page a11y/email fixes (#219)
* fix(dashboard): bind Overview tiles to per-tier caps + public-page a11y/email fixes Live UI dogfood (Pro + free) found dashboard tile data-binding bugs and a few a11y/consistency issues (distinct from the #218 marketing-copy fixes): Overview tiles (src/pages/OverviewPage.tsx): - CONNECTION LIMIT showed "∞ unlimited" for a Pro user (cap is 20). The old code summed per-resource connections_limit and flipped to ∞ the instant any resource carried -1 — redis/queue/storage/webhook are legitimately -1. Now bound to the tier's connection-bearing cap via a new plans-derived registry (src/lib/planLimits.ts), mirroring api/plans.yaml. - STORAGE denominator read a conflated ~81 GiB sum of every per-service cap. Retitled "object storage" and bound to the tier's object-store cap (storage_storage_mb; Pro = 50 GB). Numerator is object-store bytes only, so it no longer disagrees-by-conflation with Billing's per-service rows. - Recent activity rendered ~20 blank "—" rows when audit summaries were empty; now filters content-less rows and shows a single honest empty-state. Public-page a11y + consistency: - Pricing sub-labels (.pricing-feature-sub) + footer headers (.public-footer-h, .mkt-footer-col h3) used --text-faint (#50505a, 2.43:1 — fails WCAG AA). New --text-muted token (#82828e dark 5.11:1 / #62626c light 6.03:1) backs read content; --text-faint stays for ghost/decorative use only. - Home brand link aria-label "instanode home" → "instanode.dev — home" so the accessible name contains the visible text (WCAG 2.5.3 Label in Name). - Footer column headers <h4> → <h3> (no skipped heading level; WCAG 1.3.1). - Standardized general-contact CTAs (MarketingPage "talk to us", PublicShell footer "Contact") hello@ → contact@ — the canonical support address used by FAQ/cancel/billing/terms and the content repo. sales@ kept for Team/Enterprise. Tests: registry-iterating planLimits guard (every Tier pinned to plans.yaml, rule 18), tile-render assertions (Pro=20 not ∞, 50 GB object cap, empty-state), token-contrast AA guard, brand/heading/email a11y assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(overview): cover storage-tile unlimited (∞) branch for 100%-patch gate diff-cover flagged OverviewPage.tsx:237 (the `storageUnlimited ? '∞'` ternary). No plans.yaml tier has an unlimited object-storage cap today, so the branch is unreachable via real tiers — mock objectStorageLimitGBFor → -1 to exercise it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(overview): drop unused `within` import (code-quality bot) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cc788f2 commit db96d4c

14 files changed

Lines changed: 775 additions & 68 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ e2e/.cleanup-ledger.json
1717
.content/
1818

1919
coverage/
20+
.claude/worktrees/

src/layout/PublicShell.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,18 @@ describe('PublicShell — dark/light theme toggle (B3-P2-4)', () => {
9494
expect(document.documentElement.hasAttribute('data-theme')).toBe(false)
9595
})
9696
})
97+
98+
describe('PublicShell — footer support email consistency (2026-06-11)', () => {
99+
it('footer Contact link uses the canonical contact@ address, not hello@', () => {
100+
render(<PublicShell><p>hi</p></PublicShell>)
101+
const mailtos = Array.from(document.querySelectorAll('a[href^="mailto:"]')).map(
102+
(a) => a.getAttribute('href') ?? '',
103+
)
104+
expect(mailtos.some((h) => h.includes('hello@instanode.dev'))).toBe(false)
105+
const contact = Array.from(document.querySelectorAll('a')).find(
106+
(a) => (a.textContent ?? '').trim() === 'Contact',
107+
) as HTMLAnchorElement | undefined
108+
expect(contact).toBeTruthy()
109+
expect(contact!.getAttribute('href')).toContain('mailto:contact@instanode.dev')
110+
})
111+
})

src/layout/PublicShell.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,10 @@ function PublicFooter() {
263263
served as text/markdown — a visitor saw unrendered
264264
"## Reporting a vulnerability" source. */}
265265
<a href="/security">Security</a>
266-
<a href="mailto:hello@instanode.dev">Contact</a>
266+
{/* 2026-06-11: standardized on contact@ — the canonical support
267+
address (FAQ, cancel/downgrade, billing, terms, content repo).
268+
sales@ is reserved for Team/Enterprise lead capture. */}
269+
<a href="mailto:contact@instanode.dev">Contact</a>
267270
</div>
268271
</div>
269272
</div>
@@ -491,7 +494,9 @@ function PublicShellStyles() {
491494
.public-footer-h {
492495
font-family: var(--font-mono);
493496
font-size: 10.5px;
494-
color: var(--text-faint);
497+
/* WCAG AA: --text-muted (5.1:1 on --surface) not --text-faint (2.4:1).
498+
Footer column headers ("Product", "Legal") are read content. */
499+
color: var(--text-muted);
495500
text-transform: uppercase;
496501
letter-spacing: 0.06em;
497502
margin-bottom: 4px;

src/lib/planLimits.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/* planLimits.test.ts — registry-iterating guard (rule 18).
2+
*
3+
* The Overview "connection limit" and "object storage" tiles bind to these
4+
* per-tier caps. Two real production bugs motivated the binding:
5+
* - connection limit read "∞ unlimited" for a Pro user (cap is 20)
6+
* - object-storage denominator read a conflated ~81 GiB sum
7+
*
8+
* This test pins each tier's numbers to api/plans.yaml AND iterates the whole
9+
* `Tier` union so a future tier (or a renamed one) can't silently fall through
10+
* to the fallback and ship a tile bound to the wrong number. If plans.yaml
11+
* changes a connection or object-storage cap, this test fails until the mirror
12+
* is updated (rule 22 — contract change touches all surfaces).
13+
*/
14+
15+
import { describe, it, expect } from 'vitest'
16+
import {
17+
PLAN_LIMITS,
18+
planLimitsFor,
19+
connectionLimitFor,
20+
objectStorageLimitMBFor,
21+
objectStorageLimitGBFor,
22+
} from './planLimits'
23+
import type { Tier } from '../api'
24+
25+
// The full Tier union, enumerated so the iteration test below is itself not a
26+
// single-site hand-typed slice — a new tier added to the union but not here
27+
// would still be caught by ALL_TIERS coverage below (we assert PLAN_LIMITS has
28+
// exactly these keys).
29+
const ALL_TIERS: Tier[] = [
30+
'anonymous',
31+
'free',
32+
'hobby',
33+
'hobby_plus',
34+
'pro',
35+
'growth',
36+
'team',
37+
]
38+
39+
// Expected values mirror api/plans.yaml (origin/master, verified 2026-06-11).
40+
// connections = postgres_connections (== mongodb_connections == vector_connections).
41+
// objectStorageMB = storage_storage_mb.
42+
const EXPECTED: Record<Tier, { connections: number; objectStorageMB: number }> = {
43+
anonymous: { connections: 2, objectStorageMB: 10 },
44+
free: { connections: 2, objectStorageMB: 10 },
45+
hobby: { connections: 8, objectStorageMB: 512 },
46+
hobby_plus: { connections: 8, objectStorageMB: 5120 },
47+
pro: { connections: 20, objectStorageMB: 51200 },
48+
growth: { connections: 20, objectStorageMB: 153600 },
49+
team: { connections: 100, objectStorageMB: 307200 },
50+
}
51+
52+
describe('PLAN_LIMITS — every Tier has a row (rule 18)', () => {
53+
it('PLAN_LIMITS has exactly the Tier-union keys (no missing, no extra)', () => {
54+
expect(Object.keys(PLAN_LIMITS).sort()).toEqual([...ALL_TIERS].sort())
55+
})
56+
57+
for (const tier of ALL_TIERS) {
58+
it(`${tier}: connection + object-storage caps match plans.yaml`, () => {
59+
expect(PLAN_LIMITS[tier].connections).toBe(EXPECTED[tier].connections)
60+
expect(PLAN_LIMITS[tier].objectStorageMB).toBe(EXPECTED[tier].objectStorageMB)
61+
})
62+
}
63+
})
64+
65+
describe('connectionLimitFor', () => {
66+
for (const tier of ALL_TIERS) {
67+
it(`${tier}${EXPECTED[tier].connections}`, () => {
68+
expect(connectionLimitFor(tier)).toBe(EXPECTED[tier].connections)
69+
})
70+
}
71+
72+
it('Pro is a finite 20, never ∞ — the exact production bug', () => {
73+
expect(connectionLimitFor('pro')).toBe(20)
74+
expect(connectionLimitFor('pro')).toBeGreaterThan(0)
75+
})
76+
77+
it('falls back to free for an unknown/undefined tier (understate, not overstate)', () => {
78+
expect(connectionLimitFor('mystery_tier')).toBe(EXPECTED.free.connections)
79+
expect(connectionLimitFor(undefined)).toBe(EXPECTED.free.connections)
80+
expect(connectionLimitFor(null)).toBe(EXPECTED.free.connections)
81+
})
82+
})
83+
84+
describe('objectStorageLimit helpers', () => {
85+
it('Pro object cap is 50 GB (51200 MB), NOT a conflated multi-service sum', () => {
86+
expect(objectStorageLimitMBFor('pro')).toBe(51200)
87+
expect(objectStorageLimitGBFor('pro')).toBe(50)
88+
})
89+
90+
for (const tier of ALL_TIERS) {
91+
it(`${tier} object-storage GB == MB/1024`, () => {
92+
const mb = objectStorageLimitMBFor(tier)
93+
expect(objectStorageLimitGBFor(tier)).toBeCloseTo(mb / 1024, 6)
94+
})
95+
}
96+
97+
it('free shows its real 10 MB cap, never unlimited (∞)', () => {
98+
expect(objectStorageLimitMBFor('free')).toBe(10)
99+
expect(objectStorageLimitGBFor('free')).toBeGreaterThan(0)
100+
})
101+
102+
it('planLimitsFor returns the matching row object', () => {
103+
expect(planLimitsFor('team')).toEqual(PLAN_LIMITS.team)
104+
})
105+
})

src/lib/planLimits.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// planLimits — the dashboard's single, registry-shaped mirror of the per-tier
2+
// numeric caps the Overview tiles bind to.
3+
//
4+
// WHY THIS FILE EXISTS (rule 18 — registry-iterating, not hand-typed-at-call-site):
5+
// Before this, the Overview "connection limit" and "storage" tiles derived
6+
// their numbers from per-RESOURCE fields (`connections_limit` / `storage_limit_bytes`)
7+
// summed across the user's live resources. That produced two confirmed bugs on
8+
// real dashboards:
9+
//
10+
// 1. CONNECTION LIMIT showed "∞ unlimited" for a Pro user. The summing logic
11+
// flipped the whole tile to ∞ the moment ANY resource carried
12+
// connections_limit < 0 — and queue/redis/storage/webhook resources are
13+
// legitimately -1 (connection caps don't apply to them). So a Pro user
14+
// (real cap: 20 Postgres connections) with a single Redis saw ∞.
15+
//
16+
// 2. STORAGE denominator showed a conflated SUM of every per-service cap
17+
// (e.g. 50 GB object + 10 GB pg + 5 GB mongo + 10 GB vector + queue …
18+
// ≈ 81.3 GiB) presented under one "STORAGE" label. Pro's object-storage
19+
// cap is 50 GB; the tile must reflect object storage specifically, not a
20+
// sum across unlike services.
21+
//
22+
// The honest fix is to bind each tile to the TIER's published cap, not to a
23+
// derived per-resource sum. The source of truth is api/plans.yaml. The
24+
// `PLAN_LIMITS` table below mirrors it; the matching test
25+
// (planLimits.test.ts) iterates EVERY tier in the `Tier` union so a future
26+
// tier (or a renamed one) can't silently fall through to a wrong number.
27+
//
28+
// Connection semantics: only the connection-BEARING services (postgres,
29+
// mongodb, vector) have a finite per-tier connection cap. redis / queue /
30+
// storage / webhook do not take SQL-style connections — their per-resource
31+
// connections_limit is -1 by design and must NOT be read as "the tier is
32+
// unlimited". The connection tile therefore shows the connection-bearing cap
33+
// (postgres == mongodb == vector on every tier today) and is only "∞" when
34+
// that cap is itself -1 in plans.yaml (no tier is, post strict-80% redesign).
35+
36+
import type { Tier } from '../api'
37+
38+
const MB_PER_GB = 1024
39+
40+
export interface PlanLimits {
41+
/** Per-connection-bearing-service connection cap (postgres/mongodb/vector).
42+
* -1 means unlimited. plans.yaml: postgres_connections / mongodb_connections /
43+
* vector_connections — equal on every tier today. */
44+
connections: number
45+
/** Object-storage cap in MB. plans.yaml: storage_storage_mb. -1 = unlimited
46+
* (no tier today). This is the OBJECT-STORE cap only — never a sum across
47+
* postgres / mongodb / vector / queue. */
48+
objectStorageMB: number
49+
}
50+
51+
// PLAN_LIMITS — mirror of api/plans.yaml. Keep in lock-step with that file
52+
// (rule 22: a tier/limit change touches plans.yaml AND this mirror). Every
53+
// member of the `Tier` union MUST have a row — planLimits.test.ts fails if one
54+
// is missing, so a new tier can't ship a tile bound to the fallback.
55+
//
56+
// connections column source (plans.yaml, verified 2026-06-11 @ origin/master):
57+
// anonymous/free postgres_connections=2 storage_storage_mb=10
58+
// hobby postgres_connections=8 storage_storage_mb=512
59+
// hobby_plus postgres_connections=8 storage_storage_mb=5120
60+
// pro postgres_connections=20 storage_storage_mb=51200 (50 GB)
61+
// growth postgres_connections=20 storage_storage_mb=153600 (150 GB)
62+
// team postgres_connections=100 storage_storage_mb=307200 (300 GB)
63+
export const PLAN_LIMITS: Record<Tier, PlanLimits> = {
64+
anonymous: { connections: 2, objectStorageMB: 10 },
65+
free: { connections: 2, objectStorageMB: 10 },
66+
hobby: { connections: 8, objectStorageMB: 512 },
67+
hobby_plus: { connections: 8, objectStorageMB: 5120 },
68+
pro: { connections: 20, objectStorageMB: 51200 },
69+
growth: { connections: 20, objectStorageMB: 153600 },
70+
team: { connections: 100, objectStorageMB: 307200 },
71+
}
72+
73+
// Fallback used only when the live tier string is somehow outside the union
74+
// (defensive — TS guarantees the union, but the wire could in theory send a
75+
// future tier the build doesn't know yet). Free is the safest assumption: it
76+
// understates rather than overstates the user's ceiling.
77+
const FALLBACK: PlanLimits = PLAN_LIMITS.free
78+
79+
export function planLimitsFor(tier: Tier | string | undefined | null): PlanLimits {
80+
if (tier && tier in PLAN_LIMITS) return PLAN_LIMITS[tier as Tier]
81+
return FALLBACK
82+
}
83+
84+
/** The connection-bearing connection cap for a tier. -1 → unlimited. */
85+
export function connectionLimitFor(tier: Tier | string | undefined | null): number {
86+
return planLimitsFor(tier).connections
87+
}
88+
89+
/** The object-storage cap for a tier, in MB. -1 → unlimited. */
90+
export function objectStorageLimitMBFor(tier: Tier | string | undefined | null): number {
91+
return planLimitsFor(tier).objectStorageMB
92+
}
93+
94+
/** Object-storage cap as a GB number (decimal-GB to match how plans.yaml /
95+
* the pricing page talk about "50 GB"). -1 stays -1 (unlimited). */
96+
export function objectStorageLimitGBFor(tier: Tier | string | undefined | null): number {
97+
const mb = objectStorageLimitMBFor(tier)
98+
return mb < 0 ? -1 : mb / MB_PER_GB
99+
}

src/pages/MarketingPage.test.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,72 @@ describe('MarketingPage — claim consistency (T18 P1-4 / P1-6)', () => {
212212
expect(text).not.toMatch(/medium deployments/i)
213213
})
214214
})
215+
216+
// ─── 2026-06-11 a11y + email-standardization fixes ─────────────────────────
217+
describe('MarketingPage — a11y + support-email consistency', () => {
218+
function renderHome() {
219+
return render(
220+
<MemoryRouter initialEntries={['/']}>
221+
<MarketingPage />
222+
</MemoryRouter>,
223+
)
224+
}
225+
226+
it("brand link aria-label contains the visible text 'instanode.dev' (WCAG 2.5.3 Label in Name)", () => {
227+
renderHome()
228+
const brand = document.querySelector('a.mkt-brand-link') as HTMLAnchorElement
229+
expect(brand).not.toBeNull()
230+
const label = brand.getAttribute('aria-label') ?? ''
231+
// The brand renders "instanode.dev" — the accessible name must include it.
232+
expect(label).toContain('instanode.dev')
233+
// The visible text the brand renders, sans markup.
234+
expect((brand.textContent ?? '').replace(/\s+/g, '')).toContain('instanode.dev')
235+
})
236+
237+
it('footer column headers are <h3>, not <h4> (no skipped heading level)', () => {
238+
renderHome()
239+
const footerCols = Array.from(document.querySelectorAll('.mkt-footer-col'))
240+
const headers = footerCols.map((c) => c.querySelector('h1,h2,h3,h4,h5,h6'))
241+
// Every footer column has a heading and it's an <h3>.
242+
expect(headers.length).toBeGreaterThanOrEqual(2)
243+
for (const h of headers) {
244+
expect(h).not.toBeNull()
245+
expect(h!.tagName).toBe('H3')
246+
}
247+
// And no <h4> anywhere skips the level on the page.
248+
expect(document.querySelector('.mkt-footer-col h4')).toBeNull()
249+
})
250+
251+
it('heading levels never skip (no <hN> without an <hN-1> before it)', () => {
252+
renderHome()
253+
const levels = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).map((h) =>
254+
Number(h.tagName[1]),
255+
)
256+
let max = 0
257+
for (const lvl of levels) {
258+
// A heading may be at most one deeper than the deepest seen so far.
259+
expect(lvl).toBeLessThanOrEqual(max + 1)
260+
if (lvl > max) max = lvl
261+
}
262+
})
263+
264+
it("general-contact CTA uses the canonical contact@ address, not hello@", () => {
265+
renderHome()
266+
const mailtos = Array.from(document.querySelectorAll('a[href^="mailto:"]')).map(
267+
(a) => a.getAttribute('href') ?? '',
268+
)
269+
// No hello@ anywhere on the homepage.
270+
expect(mailtos.some((h) => h.includes('hello@instanode.dev'))).toBe(false)
271+
// The "talk to us" CTA points at contact@.
272+
const talk = findAnchorByText('talk to us')
273+
expect(talk).not.toBeNull()
274+
expect(talk!.getAttribute('href')).toContain('mailto:contact@instanode.dev')
275+
})
276+
277+
it('Team CTA still uses sales@ (lead capture is a deliberate split)', () => {
278+
renderHome()
279+
const teamCta = findAnchorByText('Contact sales →')
280+
expect(teamCta).not.toBeNull()
281+
expect(teamCta!.getAttribute('href')).toContain('mailto:sales@instanode.dev')
282+
})
283+
})

src/pages/MarketingPage.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,12 @@ export function MarketingPage() {
249249
{/* ---------- top nav (sticky, glassmorphic) ---------- */}
250250
<nav className="mkt-nav" aria-label="Primary">
251251
<div className="mkt-wrap mkt-nav-inner">
252-
<a href="/" className="mkt-brand-link" aria-label="instanode home">
252+
{/* WCAG 2.5.3 (Label in Name): the accessible name must contain the
253+
visible text. The brand renders "instanode.dev", so the
254+
aria-label must include "instanode.dev" (was "instanode home",
255+
which dropped the visible ".dev" — a label/name mismatch a
256+
voice-control user can't target). Matches PublicShell's brand. */}
257+
<a href="/" className="mkt-brand-link" aria-label="instanode.dev — home">
253258
<Brand />
254259
</a>
255260
{/* B1-P0-1 (2026-05-20): nav links read from the shared
@@ -612,9 +617,11 @@ export function MarketingPage() {
612617
product run in production + staging + development. Team is for the company
613618
that ships every day —{' '}
614619
{/* BugBash P3-09: the teaser said "talk to us" but had no
615-
contact path. Link to the real address used elsewhere
616-
(PublicShell footer Contact, support FAQ). */}
617-
<a href="mailto:hello@instanode.dev">talk to us</a> about your needs.
620+
contact path. 2026-06-11: standardized on contact@ — the
621+
canonical support address used by the FAQ, cancel/downgrade
622+
links, billing, terms, and the content repo. (sales@ stays
623+
for Team/Enterprise lead capture only.) */}
624+
<a href="mailto:contact@instanode.dev">talk to us</a> about your needs.
618625
</p>
619626
</div>
620627

@@ -770,15 +777,21 @@ export function MarketingPage() {
770777
<code>npm create</code> to a live URL.
771778
</p>
772779
</div>
780+
{/* WCAG 1.3.1 / heading-order: these footer column headers were
781+
<h4> while the page's deepest preceding heading was <h3> (the
782+
how-it-works step cards). An <h4> with no <h3> ancestor in the
783+
footer skips a level, breaking the document outline for screen
784+
readers. They're <h3> now — the next level below the page's
785+
<h2> sections, no skipped level. */}
773786
<div className="mkt-footer-col">
774-
<h4>Product</h4>
787+
<h3>Product</h3>
775788
<a href={ROUTES.pricing}>Pricing</a>
776789
<a href={ROUTES.forAgents}>For agents</a>
777790
<a href={ROUTES.docs}>Docs</a>
778791
<a href={ROUTES.blog}>Blog</a>
779792
</div>
780793
<div className="mkt-footer-col">
781-
<h4>Legal</h4>
794+
<h3>Legal</h3>
782795
{/* W12 H15: /privacy and /terms are real routes again
783796
(stop-gap placeholder pages with a legal@ email).
784797
/llms.txt is served from the apex by the prerender
@@ -1746,10 +1759,11 @@ const MKT_CSS = `
17461759
font-size: 12px;
17471760
color: var(--text);
17481761
}
1749-
.mkt-footer-col h4 {
1762+
.mkt-footer-col h3 {
17501763
font-family: var(--font-mono);
17511764
font-size: 11px;
1752-
color: var(--text-faint);
1765+
/* WCAG AA: --text-muted (5.1:1) not --text-faint (2.4:1) — read content. */
1766+
color: var(--text-muted);
17531767
text-transform: uppercase;
17541768
letter-spacing: 0.05em;
17551769
margin: 0 0 16px;

0 commit comments

Comments
 (0)