Skip to content

Commit b06d4dd

Browse files
committed
Finish the affordance rule, and lift the client page's first exhibit
Two gaps the last pass left, found by measuring every route rather than assuming the sweep had covered them. THE AFFORDANCE RULE WAS INCOMPLETE on the one page that promises interaction. `/dashboard` is the door card labelled "Explore it / Interactive", and its range slider (the fuel and FX shock, the most interactive control in the project) reported `cursor: default`. Gotcha 73's rule named `button, summary, [role=tab], [role=button]` and stopped there. Recharts marks are worse, because they are raw SVG and inherit nothing from an element selector. Clicking a bar on the dashboard filters every other exhibit on the page, which makes it the most useful and least discoverable interaction here; 24 clickable shapes reported a mix of pointer and default because Recharts sets it inconsistently. `.clickable-marks` goes on the series and cascades, and the test now counts onClick series against marked ones so the next chart cannot quietly ship inert. Verified: slider pointer, all 24 marked shapes pointer. Bars still reporting default belong to series with no onClick, which is correct. THE CLIENT PAGE led with a scope note instead of evidence. `unit_spread` (RASK 4.99 against CASK 5.00, inverted, and the sharpest number in the project) sat below an 88-word paragraph explaining what the page is not. The exhibit is now directly under the headline figures it explains, the scope note follows the evidence rather than blocking it, and it is a two-column is/is-not list because the point of it is what is ABSENT and absence reads faster from a list. First visual moved from 30% to 19% of page height, which is 0.64 screens at the viewport a reader actually uses. Both audit metrics were also wrong, and gotcha 77 records why. `main p` counts paragraphs inside hidden exhibit tab panels, which is exactly where this project puts detail: /company measured 18 paragraphs over 40 words and has ONE once hidden and in-panel prose is excluded. And "first visual as a percentage of page height" falls when a page gets longer, which is the opposite of an improvement. The same layout read 30%, 21% and 19% across three viewports. Measure in screens. /dashboard framing, whose first chart sits 40% down a short page, is deliberately left: the page is arranged awkwardly rather than broken, and the slider was the part that was actually broken. 204 tests, unchanged. Gotchas 77 and 78.
1 parent 959cb0a commit b06d4dd

5 files changed

Lines changed: 120 additions & 23 deletions

File tree

‎CLAUDE.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,22 @@ Every one of these cost real time or produced a wrong published number.
678678
branches where failures outnumber passes is two. Neither is three. The rule that yields
679679
three is `fails > 0 && fails >= holds`, and it is now computed and rendered rather than
680680
typed, the way `web/app/methodology` renders `pivots.length`.
681+
77. **Two ways the "wall of text" audit measures itself wrong, and both were used here.**
682+
First, `document.querySelectorAll('main p')` counts paragraphs inside exhibit TAB PANELS,
683+
which are `hidden` and are exactly where this project intends detail to live. `/company`
684+
measured 18 paragraphs over 40 words; filtering to visible, non-tabpanel prose it was
685+
**one**. Filter with `getBoundingClientRect()` and `closest('[role="tabpanel"]')`.
686+
Second, **"first visual as a percentage of page height" is not a reading experience**: it
687+
falls when the page gets longer, which is the opposite of an improvement. Measure it in
688+
SCREENS from the top, and measure at the viewport a reader actually uses. The same
689+
`/company` figure read 30%, 21% and 19% across three viewports while the layout was
690+
identical, and the honest number was 0.64 screens.
691+
78. **Recharts marks inherit no cursor from element selectors, because they are raw SVG.**
692+
A `<Bar onClick=...>` is clickable and looks inert, and on the dashboard clicking a bar
693+
filters every other exhibit, so it is the most useful and least discoverable interaction
694+
in the project. `.clickable-marks` on the series cascades to the shapes; a test counts
695+
`onClick` series against marked ones. Gotcha 73 covered `button` and missed both this and
696+
`input[type="range"]`, which is the one slider on the site.
681697
40. **The `.recon` table class sets `white-space: nowrap` on mobile.** Any new table reusing it
682698
for prose cells explodes horizontally: the option tables hit 1300px on a 335px screen. The
683699
`.options` class overrides it.

‎tests/test_delivery.py‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,8 +365,39 @@ def test_interactive_elements_keep_a_pointer_cursor():
365365
css = (Path(__file__).resolve().parent.parent / "web" / "app" / "globals.css").read_text(
366366
encoding="utf-8"
367367
)
368-
block = re.search(r"button,\s*summary,\s*\[role=\"tab\"\],\s*\[role=\"button\"\]\s*\{[^}]*\}", css)
368+
block = re.search(r"button,\s*summary,[^{]*\{[^}]*\}", css)
369369
assert block and "cursor: pointer" in block.group(0), (
370-
"globals.css no longer gives buttons, summaries and tabs a pointer cursor. "
370+
"globals.css no longer gives interactive elements a pointer cursor. "
371371
"See gotcha 73."
372372
)
373+
374+
# The first version of this rule covered buttons and tabs and missed the one
375+
# control on the site that is a slider: the fuel and FX shock on the
376+
# dashboard, which reported `cursor: default` while being the most
377+
# interactive thing in the project. Every control type the app actually uses
378+
# is named here so the next one added is a failing test rather than a dead
379+
# -feeling widget nobody notices.
380+
selector = block.group(0)
381+
for control in ('input[type="range"]', "select", "label"):
382+
assert control in selector, (
383+
f"{control} is not covered by the interactive cursor rule. "
384+
"A control that does not change the cursor reads as decoration."
385+
)
386+
387+
# Recharts draws marks as raw SVG, so a series with an onClick inherits
388+
# nothing from element selectors.
389+
assert ".clickable-marks" in css, (
390+
"the .clickable-marks rule is gone, so Recharts series with an onClick "
391+
"handler no longer signal that they are clickable"
392+
)
393+
394+
dashboard = (
395+
Path(__file__).resolve().parent.parent / "web" / "app" / "dashboard" / "page.tsx"
396+
).read_text(encoding="utf-8")
397+
series_with_click = dashboard.count("onClick={(d) =>")
398+
marked = dashboard.count('className="clickable-marks"')
399+
assert marked >= series_with_click, (
400+
f"{series_with_click} chart series take a click but only {marked} are marked "
401+
"`clickable-marks`. Clicking a bar filters every exhibit on the page, which "
402+
"is worth nothing if the reader cannot tell it is clickable."
403+
)

‎web/app/company/page.tsx‎

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,8 @@ export default function Company() {
6363
IndiGo is buying long-haul capacity from a position with no margin cushion
6464
</h1>
6565
<p className="mt-5 text-[17px] leading-relaxed text-ink/75">
66-
{brief.decision} The rest of this case argues about where those aircraft should fly.
67-
This page is about the business that has to carry them, and its central fact is that in{" "}
68-
{spread.year} unit cost sat above unit revenue.
66+
The rest of this case argues where the aircraft should fly. This page is the business
67+
that has to carry them, and its central fact is one number.
6968
</p>
7069
</header>
7170

@@ -85,21 +84,47 @@ export default function Company() {
8584
))}
8685
</section>
8786

88-
{/* Scope, stated rather than left for a reader to discover by absence. */}
89-
<section className="mt-12 border-l-2 border-ink bg-wash py-5 pl-5 pr-4">
87+
{/* The hero exhibit, directly under the numbers it explains. It used to sit
88+
below the scope box, which put the page's first visual 30% of the way
89+
down: the same defect the answer page had, on the page a reader reaches
90+
second. The scope note matters and is now BELOW the evidence rather than
91+
in front of it. */}
92+
<section aria-label="The spread" className="mt-14">
93+
<h2 className="max-w-[34ch] font-serif text-[clamp(1.4rem,2.6vw,2rem)] font-semibold">
94+
Unit cost sat above unit revenue in {spread.year}
95+
</h2>
96+
<div className="mt-8">
97+
<RegisteredExhibit id="unit_spread" />
98+
</div>
99+
</section>
100+
101+
{/* Scope, stated rather than left for a reader to discover by absence.
102+
It was one 88-word paragraph and is now a list, because the point of it
103+
is what is absent, and absence is read from a list far faster than from
104+
a sentence. */}
105+
<section className="mt-16 border-l-2 border-ink bg-wash py-5 pl-5 pr-4">
90106
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-grey">
91107
What this page is, and is not
92108
</p>
93-
<p className="mt-3 max-w-[74ch] text-[14.5px] leading-relaxed">
94-
A profit and loss, unit economics and capital scale view, built entirely from figures
95-
IndiGo has published and this project has verified against the primary document.{" "}
96-
<strong>
97-
There is no balance sheet, no return on invested capital, no cost of capital and no
98-
financing plan.
99-
</strong>{" "}
100-
Not because they would be uninteresting, but because none of the inputs has cleared this
101-
project&rsquo;s assumption gate, and a page that mixes verified figures with plausible
102-
ones is worse than a page that stops. {brief.not_this}
109+
<div className="mt-4 grid gap-x-10 gap-y-4 sm:grid-cols-2">
110+
<div>
111+
<p className="text-[13px] font-semibold">It is</p>
112+
<p className="mt-1 text-[14px] leading-relaxed text-ink/75">
113+
Profit and loss, unit economics and capital scale, every figure verified against
114+
IndiGo&rsquo;s own primary filings.
115+
</p>
116+
</div>
117+
<div>
118+
<p className="text-[13px] font-semibold text-red">It is not</p>
119+
<p className="mt-1 text-[14px] leading-relaxed text-ink/75">
120+
A balance sheet, a return on invested capital, a cost of capital or a financing
121+
plan. None of those inputs clears the assumption gate.
122+
</p>
123+
</div>
124+
</div>
125+
<p className="mt-4 max-w-[74ch] text-[13px] leading-relaxed text-grey">
126+
A page that mixes verified figures with plausible ones is worse than a page that
127+
stops. {brief.not_this}
103128
</p>
104129
</section>
105130

@@ -109,12 +134,10 @@ export default function Company() {
109134
Can it earn?
110135
</h2>
111136
<p className="mt-3 max-w-[68ch] text-[15px] leading-relaxed text-ink/75">
112-
The first branch of the driver tree, and the one that fails. Unit revenue minus unit
113-
cost times capacity is the whole profit identity for an airline, and the first term is
114-
negative.
137+
The first branch of the driver tree, and the one that fails. The spread above is the
138+
first term of the profit identity; these two say why it moved.
115139
</p>
116140
<div className="mt-10 space-y-16">
117-
<RegisteredExhibit id="unit_spread" />
118141
<RegisteredExhibit id="margin_ladder" />
119142
<RegisteredExhibit id="cost_stack" />
120143
</div>

‎web/app/dashboard/page.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,12 @@ export default function Dashboard() {
196196
{...TOOLTIP}
197197
formatter={(v, n) => `${Number(v).toFixed(1)}% of ${n === "share_pct" ? "passengers" : "revenue"}`}
198198
/>
199-
<Bar dataKey="share_pct" isAnimationActive={false} onClick={(d) => toggle((d as unknown as { region: string }).region)}>
199+
<Bar
200+
dataKey="share_pct"
201+
isAnimationActive={false}
202+
className="clickable-marks"
203+
onClick={(d) => toggle((d as unknown as { region: string }).region)}
204+
>
200205
{shown.map((c) => (
201206
<Cell key={c.region} fill={fill(c.region)} cursor="pointer" />
202207
))}
@@ -231,6 +236,7 @@ export default function Dashboard() {
231236
<Scatter
232237
data={economicCorridors.filter((c) => isOn(c.region))}
233238
isAnimationActive={false}
239+
className="clickable-marks"
234240
onClick={(d) => toggle((d as unknown as { region: string }).region)}
235241
>
236242
{economicCorridors
@@ -285,7 +291,12 @@ export default function Dashboard() {
285291
<XAxis dataKey="region" {...AXIS} interval={0} angle={-25} textAnchor="end" height={64} />
286292
<YAxis {...AXIS} width={48} unit="kg" />
287293
<Tooltip {...TOOLTIP} formatter={(v) => `${Number(v).toFixed(1)} kg per passenger`} />
288-
<Bar dataKey="kg_per_pax" isAnimationActive={false} onClick={(d) => toggle((d as unknown as { region: string }).region)}>
294+
<Bar
295+
dataKey="kg_per_pax"
296+
isAnimationActive={false}
297+
className="clickable-marks"
298+
onClick={(d) => toggle((d as unknown as { region: string }).region)}
299+
>
289300
{shown
290301
.filter((c) => c.kg_per_pax !== null)
291302
.map((c) => (

‎web/app/globals.css‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,27 @@ h3 {
7272
@layer base {
7373
button,
7474
summary,
75+
label,
76+
select,
77+
input[type="range"],
78+
input[type="checkbox"],
79+
input[type="radio"],
7580
[role="tab"],
7681
[role="button"] {
7782
cursor: pointer;
7883
}
7984

85+
/* Recharts draws its marks as raw SVG, so a bar with an `onClick` inherits
86+
nothing from the rules above. On the dashboard, clicking a bar filters every
87+
other exhibit on the page, which is the most useful interaction in the whole
88+
project and the least discoverable: 24 clickable bars reported a mix of
89+
`pointer` and `default` because Recharts sets it inconsistently. The class
90+
goes on the series and cascades to the shapes it renders. */
91+
.clickable-marks,
92+
.clickable-marks * {
93+
cursor: pointer;
94+
}
95+
8096
a {
8197
color: inherit;
8298
text-decoration: underline;

0 commit comments

Comments
 (0)