From 80a25740e1ed8f18b17a75663b86625e36c06d9d Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Wed, 28 Oct 2026 10:51:01 -0500 Subject: [PATCH 1/4] Test the hero picture link, the Pro notes and the guide order and wording. --- test/site-pages.test.ts | 87 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/test/site-pages.test.ts b/test/site-pages.test.ts index 75062ca..ad459dd 100644 --- a/test/site-pages.test.ts +++ b/test/site-pages.test.ts @@ -115,7 +115,7 @@ function plannedFeaturesOf(html: string, label: string): string[] { ); } return [...list[1].matchAll(/]*>([\s\S]*?)<\/li>/gi)].map((item) => - collapseSpaces(item[1]), + collapseSpaces(item[1].replace(/]*>[\s\S]*?<\/small>/gi, "")), ); } @@ -938,3 +938,88 @@ test("no site page says dearer or fell back to", () => { } } }); + +function textOf(html: string): string { + return collapseSpaces(html.replace(//g, "").replace(/<[^>]+>/g, "")); +} + +test("the hero picture is a link to the same sample as the hero button", () => { + const html = readPage("index.html"); + const links = [...html.matchAll(/]*>([\s\S]*?)<\/a>/gi)].map((match) => ({ + attributes: attributesOf(match[0]), + inner: match[1], + })); + const cta = links.find((link) => textOf(link.inner).startsWith("Try it with sample data")); + assert.ok(cta, "index.html has no link reading Try it with sample data: keep the hero button"); + assert.equal(cta.attributes.get("href"), "/app/?sample", "the hero button no longer opens /app/?sample"); + const frame = links.find((link) => classTokensOf(link.attributes).includes("frame")); + assert.ok(frame, 'index.html has no : make the hero picture frame a link'); + assert.equal( + frame.attributes.get("href"), + cta.attributes.get("href"), + `the hero picture links to ${frame.attributes.get("href")}: link it to ${cta.attributes.get("href")}, like the hero button`, + ); + assert.equal( + frame.attributes.get("aria-label"), + "Try it with sample data", + 'the hero picture link has no aria-label="Try it with sample data": name the link for screen readers', + ); + assert.ok( + frame.inner.includes('class="wf wf-wide"'), + "the hero picture link does not hold the bridge drawing: wrap the whole frame", + ); +}); + +const AI_WORKSPACE_BUILDS = [true, false].flatMap((ai) => + [true, false].map((workspaces) => ({ ai, workspaces })), +); + +test("the Pro card says it has no launch date and where AI commentary sends data", () => { + for (const flags of AI_WORKSPACE_BUILDS) { + const build = JSON.stringify(flags); + const index = applyFeatureBlocks(readPage("index.html"), flags); + const card = planCardsOf(index).find((candidate) => candidate.inner.includes("

Pro

")); + assert.ok(card, `index.html in the ${build} build has no Pro plan card`); + const text = textOf(card.inner); + const price = text.indexOf("$19 per user per month, expected"); + const when = text.indexOf("No launch date yet."); + assert.ok(price !== -1, `the Pro card in the ${build} build lost its price`); + assert.ok(when > price, `the Pro card in the ${build} build does not say No launch date yet. after the price`); + assert.ok( + text.includes("Sends the bridge totals, never your file, to Anthropic's Claude. See the privacy page."), + `the Pro card in the ${build} build reads "${text}": add the AI commentary note under its item`, + ); + const privacyLinks = [...card.inner.matchAll(/]*\shref="\/privacy\.html#([^"]+)"/gi)]; + assert.equal(privacyLinks.length, 1, `the Pro card in the ${build} build has ${privacyLinks.length} privacy page links: keep one`); + const privacy = applyFeatureBlocks(readPage("privacy.html"), flags); + assert.ok( + privacy.includes(`id="${privacyLinks[0][1]}"`), + `the Pro card in the ${build} build links to /privacy.html#${privacyLinks[0][1]}, which that build of privacy.html does not have`, + ); + } +}); + +test("the guide shows the column table right after it says these columns:", () => { + const html = readPage("guide.html"); + const colon = html.indexOf("these columns:

"); + assert.ok(colon !== -1, "guide.html no longer ends a paragraph with these columns:"); + const next = html.slice(colon + "these columns:

".length).trimStart(); + assert.ok(next.startsWith('
'), "guide.html puts something between these columns: and the column table"); + const table = html.indexOf("", colon); + const numbers = html.indexOf("

Numbers written like 12,50", colon); + assert.ok(numbers > table, "guide.html puts the numbers paragraph before the column table: move it after the table"); +}); + +test("the guide says when to keep an attribute that describes the customer", () => { + const guide = visibleWordsOf(readPage("guide.html")).join(" "); + for (const sentence of [ + "Unless it describes the customer, not the product: that one goes last or can be left out.", + "leaving it out raises the match rate", + ]) { + assert.ok(guide.includes(sentence), `guide.html does not say "${sentence}"`); + } + assert.ok( + !guide.includes("If it barely moves price and raises the match rate"), + "guide.html still says the attribute itself raises the match rate: say leaving it out raises it", + ); +}); From f89e2c48d02b9e75a4b6c4f919192948d53cfe2e Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Wed, 28 Oct 2026 10:52:05 -0500 Subject: [PATCH 2/4] Link the hero picture to the sample, add the Pro notes, enlarge phone tap targets, reorder and reword the guide, and define a bridge on the home page. --- guide.html | 6 +++--- index.html | 10 ++++++---- privacy.html | 4 ++-- src/site/home.css | 36 ++++++++++++++++++++++++++++++++---- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/guide.html b/guide.html index c93eff4..91e98b0 100644 --- a/guide.html +++ b/guide.html @@ -37,7 +37,6 @@

Run your first bridge

1. Check your file

Use one row per sales line, or per group of lines. A summarized export works as well as invoice detail, as long as it has these columns:

-

Numbers written like 12,50 or 1.234,56 are read with the comma as the decimal mark when every value in the column is written that way. Whole numbers written like 9.600 or 1.234.567 are read with the dot between thousands when the file is separated by semicolons or a value has more than one dot. The Data check tab says when either happened.

@@ -52,6 +51,7 @@

1. Check your file

ColumnNeededNotes
+

Numbers written like 12,50 or 1.234,56 are read with the comma as the decimal mark when every value in the column is written that way. Whole numbers written like 9.600 or 1.234.567 are read with the dot between thousands when the file is separated by semicolons or a value has more than one dot. The Data check tab says when either happened.

CSV and Excel (.xlsx, first sheet) files are accepted. The file is read in your browser and is not uploaded. To see the layout, download a small sample file of made-up sales.

2. Load and set up

@@ -93,8 +93,8 @@

7. Test your choice of attributes

The setup check tab: the bridge recalculated with each attribute left out

Setup check reruns the bridge with each attribute left out.

    -
  • If leaving an attribute out moves price a lot, that attribute carries real mix. Keep it.
  • -
  • If it barely moves price and raises the match rate, the attribute is adding noise. Consider dropping it.
  • +
  • If leaving an attribute out moves price a lot, that attribute carries real mix. Keep it. Unless it describes the customer, not the product: that one goes last or can be left out.
  • +
  • If it barely moves price and leaving it out raises the match rate, the attribute is adding noise. Consider dropping it.

8. Look across more than two periods

diff --git a/index.html b/index.html index 83b7cce..4cf6648 100644 --- a/index.html +++ b/index.html @@ -40,12 +40,13 @@

Price-volume-mix (PVM) analysis

See why sales moved.

Volume, price, and a mix step for every product attribute, in one bridge.

+

A bridge is a chart that starts at last period's sales, adds or subtracts each cause, and ends at this period's sales.

Try it with sample data

Free. No account. CSV or Excel.
Your file stays in your browser.

- +
@@ -182,7 +183,7 @@

What you get

For the analyst who has to explain a change in sales or margin. Load a CSV or Excel export, rank your attributes, and read the bridge.

Bridge
-
The waterfall, a mix line for each attribute, a written summary, and an Excel export.
+
The bridge chart, a mix line for each attribute, a written summary, and an Excel export.
Movers
What sits behind each bar, drilled down by attribute.
Accounts
@@ -222,10 +223,11 @@

Free

Planned

Pro

$19 per user per month, expected
+

No launch date yet.

  • Saved workspaces across devices
  • Shared setups for a team
  • -
  • AI commentary on each bridge
  • +
  • AI commentary on each bridgeSends the bridge totals, never your file, to Anthropic's Claude. See the privacy pageprivacy page.
  • PowerPoint export
Follow on GitHub diff --git a/privacy.html b/privacy.html index 8be51fb..46f94d3 100644 --- a/privacy.html +++ b/privacy.html @@ -60,7 +60,7 @@

What is stored, and where

-

Optional AI commentary

+

Optional AI commentary

  • Below the summary there is a button to have Anthropic’s Claude write a longer commentary. Nothing is sent unless you press it, each time.
  • What is sent: the bridge totals, the names of the measure, attributes and bridge lines, the two period labels and the match coverage. You can see the exact text under Show exactly what is sent before you press the button.
  • @@ -100,7 +100,7 @@

    What we can see

    Checking this yourself

    Open your browser’s developer tools, go to the network panel, and load a file. No request carries your data. The pages also carry a content security policy that allows connections only to this site, so the browser itself blocks anything else. The checkout page allows connections to Paddle, which takes the payment. The source code is published so anyone can confirm what the page does, and its automated test checks that none of your data is stored or sent.

    -

    If this changes

    +

    If this changes

    Planned Pro features would use a server: saved workspaces would store your setup there, and AI commentary would send the bridge totals, never your file or its rows, to Anthropic’s Claude to write the text. This page will be updated before either is released, and uploading data will never be required to use the free tool.

    diff --git a/src/site/home.css b/src/site/home.css index 9b24c7f..6559d4f 100644 --- a/src/site/home.css +++ b/src/site/home.css @@ -93,6 +93,8 @@ a.button.ghost:hover { border-color: var(--plum); background: var(--blush); colo .hero .copy .lead { margin-top: 1.5rem; font-size: 1.3125rem; line-height: 1.45; color: var(--plum-2); max-width: 21rem; } +.hero .copy .define { margin-top: .75rem; max-width: 21rem; font: 400 .9375rem/1.5 var(--sans); color: var(--plum-2); } + .hero .copy .cta { margin-top: 2.25rem; padding: .95rem 1.35rem; font-size: .9375rem; } .hero .copy .cta span { transition: transform .15s; } @@ -104,11 +106,11 @@ a.button.ghost:hover { border-color: var(--plum); background: var(--blush); colo /* In the side-by-side layout, the picture runs into the shell's right padding to leave room for all five KPI tiles. */ .hero figure { margin-right: calc(-1 * var(--pad) - min(0px, var(--gutter) - 2rem)); } -/* The framed picture is a drawing of the tool, so nothing in it answers a pointer, a tap or a long press. */ -.hero figure { pointer-events: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; } +/* The picture links to the sample but still reads as a drawing: no link colour or underline, and no text selection. */ +.hero .frame { display: block; background: var(--t-bg); border-radius: 12px; overflow: hidden; font-family: var(--sans); color: var(--t-text); text-decoration: none; user-select: none; -webkit-user-select: none; + box-shadow: 0 0 0 1px rgba(66, 32, 61, .10), 0 2px 4px rgba(66, 32, 61, .06), 0 24px 60px -12px rgba(66, 32, 61, .35); transition: transform .2s ease, box-shadow .2s ease; } -.hero .frame { background: var(--t-bg); border-radius: 12px; overflow: hidden; font-family: var(--sans); color: var(--t-text); - box-shadow: 0 0 0 1px rgba(66, 32, 61, .10), 0 2px 4px rgba(66, 32, 61, .06), 0 24px 60px -12px rgba(66, 32, 61, .35); } +.hero a.frame:focus-visible { outline: 3px solid var(--plum); outline-offset: 4px; } .hero .chrome { display: flex; align-items: center; gap: .9rem; height: 44px; padding: 0 1rem; border-bottom: 1px solid var(--t-line); } @@ -168,6 +170,20 @@ a.button.ghost:hover { border-color: var(--plum); background: var(--blush); colo .hero .frame { max-width: 48rem; transform: rotate(-1.5deg); } } +@media (hover: hover) { + .hero a.frame:hover { transform: translateY(-4px); box-shadow: 0 0 0 1px rgba(66, 32, 61, .16), 0 4px 8px rgba(66, 32, 61, .08), 0 32px 72px -12px rgba(66, 32, 61, .45); } + + .hero-grid:has(a.frame:hover) .cta span { transform: translateX(3px); } +} + +@media (max-width: 1239px) and (hover: hover) { + .hero a.frame:hover { transform: rotate(-1.5deg) translateY(-4px); } +} + +@media (prefers-reduced-motion: reduce) { + .hero .frame, .hero .copy .cta span { transition: none; } +} + @media (max-width: 800px) { .hero .kpis { grid-template-columns: 1fr 1fr; } @@ -279,10 +295,14 @@ a.button.ghost:hover { border-color: var(--plum); background: var(--blush); colo .plan p { margin-top: 0.5rem; } +.plan .when { margin-top: 0.25rem; font: 400 0.8125rem/1.5 "Instrument Sans", system-ui, -apple-system, "Segoe UI", Arial, sans-serif; color: var(--plum-2); } + .plan ul { margin: 1.25rem 0 1.75rem; padding-left: 1.1rem; display: grid; gap: 0.4rem; } .plan li { font-size: 1.0625rem; line-height: 1.45; } +.plan li small { display: block; margin-top: 0.15rem; font: 400 0.8125rem/1.5 "Instrument Sans", system-ui, -apple-system, "Segoe UI", Arial, sans-serif; color: var(--plum-2); } + /* Footer */ footer { padding-block: 2rem; border-top: 1px solid var(--blush); } @@ -324,6 +344,14 @@ footer { padding-block: 2rem; border-top: 1px solid var(--blush); } .nav nav { width: 100%; justify-content: space-between; gap: 0.5rem; } } +@media (pointer: coarse), (max-width: 480px) { + .wordmark, .nav nav a, .footer-inner a { min-height: 44px; } + + .nav nav a:not(.button), .footer-inner a { display: inline-flex; align-items: center; } + + .footer-inner { row-gap: 0; } +} + @media (max-width: 34rem) { .get dl { grid-template-columns: 1fr; gap: 0.15rem; } From 59db796e083a6ea8c6c47ab22d1f9408ae8c2c12 Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Wed, 28 Oct 2026 10:53:05 -0500 Subject: [PATCH 3/4] Read the hero link attributes from its opening tag only. --- test/site-pages.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/site-pages.test.ts b/test/site-pages.test.ts index ad459dd..7ae3ea2 100644 --- a/test/site-pages.test.ts +++ b/test/site-pages.test.ts @@ -945,9 +945,9 @@ function textOf(html: string): string { test("the hero picture is a link to the same sample as the hero button", () => { const html = readPage("index.html"); - const links = [...html.matchAll(/]*>([\s\S]*?)<\/a>/gi)].map((match) => ({ - attributes: attributesOf(match[0]), - inner: match[1], + const links = [...html.matchAll(/(]*>)([\s\S]*?)<\/a>/gi)].map((match) => ({ + attributes: attributesOf(match[1]), + inner: match[2], })); const cta = links.find((link) => textOf(link.inner).startsWith("Try it with sample data")); assert.ok(cta, "index.html has no link reading Try it with sample data: keep the hero button"); From b65a2f8c085823f323946e1163f234e3bf87a2b8 Mon Sep 17 00:00:00 2001 From: Marcus Gibson Date: Wed, 28 Oct 2026 10:54:55 -0500 Subject: [PATCH 4/4] Describe the site page fixes for the hero link, Pro notes, tap targets, guide and bridge wording. --- changes/performance%2Fd127.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changes/performance%2Fd127.md diff --git a/changes/performance%2Fd127.md b/changes/performance%2Fd127.md new file mode 100644 index 0000000..acadcc6 --- /dev/null +++ b/changes/performance%2Fd127.md @@ -0,0 +1,5 @@ +- The picture of the tool on the home page is now a link to the sample data, named "Try it with sample data" like the button beside it. It lifts slightly on hover where a mouse is used, shows a pointer cursor, and has a focus outline. Under reduced motion it does not animate. +- The Pro column says "No launch date yet." after its price. The AI commentary item now says "Sends the bridge totals, never your file, to Anthropic's Claude. See the privacy page." The link goes to the privacy page's AI commentary section when the build has AI commentary, and to its "If this changes" section when it does not. `privacy.html` gains the two section ids `ai-commentary` and `if-this-changes`. +- On phones (coarse pointer or width up to 480 px), every header and footer link on every site page is at least 44 px tall. The desktop look does not change. +- The guide's section 1 shows the column table right after "these columns:", followed by the paragraph on number formats. Section 7 now says "If leaving an attribute out moves price a lot, that attribute carries real mix. Keep it. Unless it describes the customer, not the product: that one goes last or can be left out." and "If it barely moves price and leaving it out raises the match rate, the attribute is adding noise. Consider dropping it." +- The home page explains its first "bridge" with "A bridge is a chart that starts at last period's sales, adds or subtracts each cause, and ends at this period's sales." The "What you get" list now reads "The bridge chart, a mix line for each attribute, a written summary, and an Excel export." where it said "The waterfall".