Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions changes/performance%2Fd127.md
Original file line number Diff line number Diff line change
@@ -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".
6 changes: 3 additions & 3 deletions guide.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ <h1>Run your first bridge</h1>

<h2>1. Check your file</h2>
<p>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:</p>
<p>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.</p>
<div class="boxed">
<table>
<thead><tr><th>Column</th><th>Needed</th><th>Notes</th></tr></thead>
Expand All @@ -52,6 +51,7 @@ <h2>1. Check your file</h2>
</tbody>
</table>
</div>
<p>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.</p>
<p>CSV and Excel (.xlsx, first sheet) files are accepted. The file is read in your browser and is not uploaded. To see the layout, <a href="/sample-sales.csv" download>download a small sample file</a> of made-up sales.</p>

<h2>2. Load and set up</h2>
Expand Down Expand Up @@ -93,8 +93,8 @@ <h2>7. Test your choice of attributes</h2>
<figure class="shot" style="margin-top: 16px"><picture><source type="image/avif" srcset="/img/g-setup.avif" /><img src="/img/g-setup.png" alt="The setup check tab: the bridge recalculated with each attribute left out" width="1280" height="860" loading="lazy" decoding="async" style="width: 100%; display: block; height: auto" /></picture></figure>
<p style="margin-top: 16px"><b>Setup check</b> reruns the bridge with each attribute left out.</p>
<ul>
<li>If leaving an attribute out moves price a lot, that attribute carries real mix. Keep it.</li>
<li>If it barely moves price and raises the match rate, the attribute is adding noise. Consider dropping it.</li>
<li>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.</li>
<li>If it barely moves price and leaving it out raises the match rate, the attribute is adding noise. Consider dropping it.</li>
</ul>

<h2>8. Look across more than two periods</h2>
Expand Down
10 changes: 6 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,13 @@
<p class="eyebrow">Price-volume-mix (PVM) analysis</p>
<h1>See why sales moved.</h1>
<p class="lead">Volume, price, and a mix step for every product attribute, in one bridge.</p>
<p class="define">A bridge is a chart that starts at last period's sales, adds or subtracts each cause, and ends at this period's sales.</p>
<a class="button cta" href="/app/?sample">Try it with sample data <span aria-hidden="true">→</span></a>
<p class="fine">Free. No account. CSV or Excel.<br />Your file stays in your browser.</p>
</div>

<figure>
<div class="frame">
<a class="frame" href="/app/?sample" aria-label="Try it with sample data">
<div class="chrome">
<span class="dots"><i></i><i></i><i></i></span>
<span class="app-name"><svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="6" fill="#1f4b99"/><rect x="6" y="17" width="5" height="9" fill="#fff"/><rect x="13.5" y="12" width="5" height="5" fill="#fff" opacity=".75"/><rect x="21" y="6" width="5" height="20" fill="#fff"/></svg>PVM Bridge</span>
Expand Down Expand Up @@ -170,7 +171,7 @@ <h1>See why sales moved.</h1>
</svg>

</div>
</div>
</a>
</figure>
</div>
</section>
Expand All @@ -182,7 +183,7 @@ <h2>What you get</h2>
<p class="lead">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.</p>
<dl>
<dt>Bridge</dt>
<dd>The waterfall, a mix line for each attribute, a written summary, and an Excel export.</dd>
<dd>The bridge chart, a mix line for each attribute, a written summary, and an Excel export.</dd>
<dt>Movers</dt>
<dd>What sits behind each bar, drilled down by attribute.</dd>
<dt>Accounts</dt>
Expand Down Expand Up @@ -222,10 +223,11 @@ <h3>Free</h3>
<span class="tag muted">Planned</span>
<h3>Pro</h3>
<div class="price">$19 <span>per user per month, expected</span></div>
<p class="when">No launch date yet.</p>
<ul>
<li>Saved workspaces across devices</li>
<li>Shared setups for a team</li>
<li>AI commentary on each bridge</li>
<li>AI commentary on each bridge<small>Sends the bridge totals, never your file, to Anthropic's Claude. See the <!-- feature:ai --><a href="/privacy.html#ai-commentary">privacy page</a><!-- /feature:ai --><!-- feature:!ai --><a href="/privacy.html#if-this-changes">privacy page</a><!-- /feature:!ai -->.</small></li>
<li>PowerPoint export</li>
</ul>
<a class="button ghost" href="https://github.com/creedants/bridgekit">Follow on GitHub</a>
Expand Down
4 changes: 2 additions & 2 deletions privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ <h2>What is stored, and where</h2>
</ul>

<!-- feature:ai -->
<h2>Optional AI commentary</h2>
<h2 id="ai-commentary">Optional AI commentary</h2>
<ul>
<li>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.</li>
<li>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 <b>Show exactly what is sent</b> before you press the button.</li>
Expand Down Expand Up @@ -100,7 +100,7 @@ <h2>What we can see</h2>
<h2>Checking this yourself</h2>
<p>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.<!-- feature:workspaces --> The checkout page allows connections to Paddle, which takes the payment.<!-- /feature:workspaces --> 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.</p>

<h2>If this changes</h2>
<h2 id="if-this-changes">If this changes</h2>
<!-- feature:!workspaces -->
<p>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.</p>
<!-- /feature:!workspaces -->
Expand Down
36 changes: 32 additions & 4 deletions src/site/home.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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); }

Expand Down Expand Up @@ -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; }

Expand Down Expand Up @@ -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); }
Expand Down Expand Up @@ -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; }

Expand Down
87 changes: 86 additions & 1 deletion test/site-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ function plannedFeaturesOf(html: string, label: string): string[] {
);
}
return [...list[1].matchAll(/<li\b[^>]*>([\s\S]*?)<\/li>/gi)].map((item) =>
collapseSpaces(item[1]),
collapseSpaces(item[1].replace(/<small\b[^>]*>[\s\S]*?<\/small>/gi, "")),
);
}

Expand Down Expand Up @@ -938,3 +938,88 @@ test("no site page says dearer or fell back to", () => {
}
}
});

function textOf(html: string): string {
return collapseSpaces(html.replace(/<!--[\s\S]*?-->/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(/(<a\b[^>]*>)([\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");
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 <a class="frame">: 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("<h3>Pro</h3>"));
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(/<a\b[^>]*\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:</p>");
assert.ok(colon !== -1, "guide.html no longer ends a paragraph with these columns:");
const next = html.slice(colon + "these columns:</p>".length).trimStart();
assert.ok(next.startsWith('<div class="boxed">'), "guide.html puts something between these columns: and the column table");
const table = html.indexOf("</table>", colon);
const numbers = html.indexOf("<p>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",
);
});
Loading