Skip to main content

max / makenotwork

Disclose founder pricing on /pricing, and compute on it The landing page leads with founder pricing at half of list. /pricing did not mention it anywhere, so a visitor walking landing -> pricing saw the full price and no sign the discount existed. That matters more here than on a page of numbers, because /pricing is the fee calculator rather than a price list. The tier radio's value is what hx-include sends to /pricing/compare, so it is the price the server computes the outcome on. A visitor inside the founder window was being shown what they keep at a price they would not pay. Two parts, both gated on founder_window_open, read from the same config the landing page reads so the two cannot disagree about whether the offer is live: The banner reuses the landing tagline's wording and its .founder-tagline classes. Same offer, so a second phrasing for it would read as a second offer, and the class reuse means no new CSS. The toggle is <mnw-price-mode>, an island over the tier cards. Each card publishes both prices as data-price-std and data-price-founder, so flipping is a local swap with no round trip for the prices; the recompute is the calculator's existing one, re-fired by dispatching change on the checked tier radio, which is what hx-trigger already listens for. Pure logic sits in price-mode.logic.ts under node --test, including the case that made it worth extracting: getAttribute returns "" for a present-but-empty attribute, and writing that into the radio value would send an empty tier price to the calculator. The server renders the founder price as the default, so the flip is the only thing JS is load-bearing for. Without it the page still opens on the price the visitor would actually pay. With the window shut nothing above renders and the page is what it was. The harness gained founder_window_open and with_founder_window_open(), since every existing test runs with it shut and neither surface was otherwise reachable; both states are now asserted.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 22:33 UTC
Signed with PGP, not checked
Commit: b63fcc20432d71ce7768f22155e64488b2ae4cdd
Parent: 957d9d6
10 files changed, +303 insertions, -9 deletions
@@ -6297,6 +6297,50 @@
6297 6297 opacity: 0.5;
6298 6298 }
6299 6299
6300 + /* <mnw-price-mode> is a custom element (default display:inline); it wraps the
6301 + toggle and the tier grid, so it has to lay out as a block. */
6302 + mnw-price-mode {
6303 + display: block;
6304 + }
6305 +
6306 + /* List-vs-founder toggle above the tier cards, rendered only while the founder
6307 + window is open. Segmented control rather than a checkbox: both options are
6308 + real prices the visitor can be shown, and neither is an "off" state. Follows
6309 + .tier-option's pattern of hiding the radio and styling the label off
6310 + :has(:checked), so the selection needs no JS class. */
6311 + .price-mode {
6312 + display: inline-flex;
6313 + gap: 0;
6314 + margin-bottom: var(--gap-section);
6315 + border: 1px solid var(--border);
6316 + border-radius: var(--radius-control);
6317 + overflow: hidden;
6318 + }
6319 +
6320 + .price-mode-option {
6321 + cursor: pointer;
6322 + font-family: var(--font-mono);
6323 + font-size: var(--text-note);
6324 + padding: var(--gap-bound) var(--gap-section);
6325 + transition: background-color 0.15s ease;
6326 + }
6327 +
6328 + .price-mode-option input[type="radio"] {
6329 + position: absolute;
6330 + opacity: 0;
6331 + pointer-events: none;
6332 + }
6333 +
6334 + .price-mode-option:has(input[type="radio"]:checked) {
6335 + background: var(--action);
6336 + color: var(--content-on-action);
6337 + }
6338 +
6339 + .price-mode-option:focus-within {
6340 + outline: 2px solid var(--focus-ring);
6341 + outline-offset: -2px;
6342 + }
6343 +
6300 6344 .tier-selector {
6301 6345 display: grid;
6302 6346 grid-template-columns: 1fr 1fr;
@@ -53,35 +53,71 @@
53 53 </div>
54 54 </div>
55 55
56 + {# Founder disclosure. Same wording as the landing tagline
57 + (index.html), because it is the same offer and a second phrasing
58 + for it would read as a second offer. Both this and the toggle
59 + below are gated: with the window shut the page is what it was
60 + before either existed. #}
61 + {% if founder_window_open %}
62 + <p class="founder-tagline">
63 + <span class="founder-tagline-mark">Founder pricing open</span>
64 + <span class="founder-tagline-detail">Half off creator tiers, <strong>locked for life</strong>. The calculator is using founder prices.</span>
65 + </p>
66 + {% endif %}
67 +
56 68 <div class="tier-section">
57 69 <h2 class="section-label">Your content tier</h2>
58 70 <p class="tier-intro">Every tier is the complete platform: profile, project pages, forum, discovery, memberships, analytics, full data export. The tier picks the file-size envelope, not the feature set.</p>
71 +
72 + {# The radio `value` is what hx-include sends, so it is the price
73 + the server computes on. While the window is open it opens on
74 + the founder price: a visitor who can buy at half price should
75 + not be shown their outcome at a price they will not pay.
76 + <mnw-price-mode> swaps between the two data-price-* values in
77 + the DOM and re-fires the existing recompute. Without JS the
78 + toggle is absent and the founder price stands, which is the
79 + right default rather than a broken one. #}
80 + <mnw-price-mode>
81 + {% if founder_window_open %}
82 + <div class="price-mode" role="radiogroup" aria-label="Calculate with">
83 + <label class="price-mode-option">
84 + <input type="radio" name="price-mode" value="founder" checked>
85 + <span>Founder price</span>
86 + </label>
87 + <label class="price-mode-option">
88 + <input type="radio" name="price-mode" value="list">
89 + <span>List price</span>
90 + </label>
91 + </div>
92 + {% endif %}
93 +
59 94 <div class="tier-selector" role="radiogroup" aria-label="Content tier">
60 95 <label class="card--bordered tier-card tier-option">
61 - <input type="radio" name="tier" value="{{ tier_prices.basic_std }}" checked>
96 + <input type="radio" name="tier" value="{% if founder_window_open %}{{ tier_prices.basic_founder }}{% else %}{{ tier_prices.basic_std }}{% endif %}" data-price-std="{{ tier_prices.basic_std }}" data-price-founder="{{ tier_prices.basic_founder }}" checked>
62 97 <div class="tier-name">Basic</div>
63 - <div class="tier-price">${{ tier_prices.basic_std }}/mo</div>
98 + <div class="tier-price" data-price-display>${% if founder_window_open %}{{ tier_prices.basic_founder }}{% else %}{{ tier_prices.basic_std }}{% endif %}/mo</div>
64 99 <div class="tier-desc">{{ tier_prices.basic_per_file }}/file, {{ tier_prices.basic_total }} total. Fits text, blogs, newsletters.</div>
65 100 </label>
66 101 <label class="card--bordered tier-card tier-option">
67 - <input type="radio" name="tier" value="{{ tier_prices.small_files_std }}">
102 + <input type="radio" name="tier" value="{% if founder_window_open %}{{ tier_prices.small_files_founder }}{% else %}{{ tier_prices.small_files_std }}{% endif %}" data-price-std="{{ tier_prices.small_files_std }}" data-price-founder="{{ tier_prices.small_files_founder }}">
68 103 <div class="tier-name">Small Files</div>
69 - <div class="tier-price">${{ tier_prices.small_files_std }}/mo</div>
104 + <div class="tier-price" data-price-display>${% if founder_window_open %}{{ tier_prices.small_files_founder }}{% else %}{{ tier_prices.small_files_std }}{% endif %}/mo</div>
70 105 <div class="tier-desc">{{ tier_prices.small_files_per_file }}/file, {{ tier_prices.small_files_total }} total. Fits audio, plugins, binaries.</div>
71 106 </label>
72 107 <label class="card--bordered tier-card tier-option">
73 - <input type="radio" name="tier" value="{{ tier_prices.big_files_std }}">
108 + <input type="radio" name="tier" value="{% if founder_window_open %}{{ tier_prices.big_files_founder }}{% else %}{{ tier_prices.big_files_std }}{% endif %}" data-price-std="{{ tier_prices.big_files_std }}" data-price-founder="{{ tier_prices.big_files_founder }}">
74 109 <div class="tier-name">Big Files</div>
75 - <div class="tier-price">${{ tier_prices.big_files_std }}/mo</div>
110 + <div class="tier-price" data-price-display>${% if founder_window_open %}{{ tier_prices.big_files_founder }}{% else %}{{ tier_prices.big_files_std }}{% endif %}/mo</div>
76 111 <div class="tier-desc">{{ tier_prices.big_files_per_file }}/file, {{ tier_prices.big_files_total }} total. Fits video, games, large software.</div>
77 112 </label>
78 113 <label class="card--bordered tier-card tier-option">
79 - <input type="radio" name="tier" value="{{ tier_prices.everything_std }}">
114 + <input type="radio" name="tier" value="{% if founder_window_open %}{{ tier_prices.everything_founder }}{% else %}{{ tier_prices.everything_std }}{% endif %}" data-price-std="{{ tier_prices.everything_std }}" data-price-founder="{{ tier_prices.everything_founder }}">
80 115 <div class="tier-name">Everything</div>
81 - <div class="tier-price">${{ tier_prices.everything_std }}/mo</div>
116 + <div class="tier-price" data-price-display>${% if founder_window_open %}{{ tier_prices.everything_founder }}{% else %}{{ tier_prices.everything_std }}{% endif %}/mo</div>
82 117 <div class="tier-desc">Big Files envelope plus first access to high-cost features as they ship.</div>
83 118 </label>
84 119 </div>
120 + </mnw-price-mode>
85 121 </div>
86 122
87 123 <div class="tier-section">
@@ -122,6 +122,10 @@
122 122 /// Set `Some(RateLimits::production())` to assert the thresholds that ship,
123 123 /// which is what `workflows::rate_limiting` does.
124 124 pub rate_limits: Option<makenotwork::constants::RateLimits>,
125 + /// Whether founder pricing is on offer. Defaults to closed, which is the
126 + /// state every pre-existing test was written against. Set it to exercise
127 + /// the surfaces that only exist while the window is open.
128 + pub founder_window_open: bool,
125 129 }
126 130
127 131 /// Full test harness: isolated database, in-process app, cookie-aware client.
@@ -169,6 +173,18 @@
169 173 .await
170 174 }
171 175
176 + /// Harness with founder pricing on offer. Every other harness runs with the
177 + /// window shut, so this is the only way to reach the banner and the
178 + /// list/founder toggle on `/pricing`.
179 + #[allow(dead_code)]
180 + pub(crate) async fn with_founder_window_open() -> Self {
181 + Self::build(BuildOptions {
182 + founder_window_open: true,
183 + ..Default::default()
184 + })
185 + .await
186 + }
187 +
172 188 /// Harness with in-memory storage backend.
173 189 #[allow(dead_code)]
174 190 pub(crate) async fn with_storage() -> Self {
@@ -416,7 +432,7 @@
416 432 tier_annual_prices: std::collections::HashMap::new(),
417 433 tier_founder_prices: std::collections::HashMap::new(),
418 434 tier_founder_annual_prices: std::collections::HashMap::new(),
419 - founder_window_open: false,
435 + founder_window_open: opts.founder_window_open,
420 436 },
421 437 integrations: IntegrationsConfig {
422 438 mt_base_url: None,
@@ -368,3 +368,71 @@
368 368 "paywall must not list the items themselves"
369 369 );
370 370 }
371 +
372 + // ── /pricing founder disclosure ──
373 + //
374 + // The page is the fee calculator, not a price list: the tier radio's `value` is
375 + // what hx-include sends, so it is the price the server computes the visitor's
376 + // outcome on. While founder pricing is on offer that value has to be the
377 + // founder price, or a visitor who can buy at half price is shown their outcome
378 + // at a price they will not pay.
379 +
380 + /// With the window shut the page is what it was before any of this existed:
381 + /// list prices, no banner, no toggle. No dead control.
382 + #[tokio::test]
383 + async fn pricing_page_without_founder_window_shows_list_prices_only() {
384 + let mut h = TestHarness::new().await;
385 +
386 + let resp = h.client.get("/pricing").await;
387 + assert_eq!(resp.status, 200);
388 + assert!(
389 + !resp.text.contains("Founder pricing open"),
390 + "founder banner rendered with the window shut"
391 + );
392 + assert!(
393 + !resp.text.contains(r#"name="price-mode""#),
394 + "price toggle rendered with the window shut"
395 + );
396 +
397 + let prices = makenotwork::tier_prices::TierPrices::global();
398 + assert!(
399 + resp.text
400 + .contains(&format!(r#"value="{}""#, prices.basic_std)),
401 + "the Basic radio should carry the list price"
402 + );
403 + }
404 +
405 + /// With the window open the offer is disclosed and the calculator opens on the
406 + /// founder price, with both prices in the DOM so the toggle is a local swap.
407 + #[tokio::test]
408 + async fn pricing_page_with_founder_window_computes_on_founder_prices() {
409 + let mut h = TestHarness::with_founder_window_open().await;
410 +
411 + let resp = h.client.get("/pricing").await;
412 + assert_eq!(resp.status, 200);
413 + assert!(
414 + resp.text.contains("Founder pricing open"),
415 + "the offer is live and undisclosed on the page that prices it"
416 + );
417 + assert!(
418 + resp.text.contains(r#"name="price-mode""#),
419 + "no way to see the post-window number"
420 + );
421 +
422 + let prices = makenotwork::tier_prices::TierPrices::global();
423 + assert_ne!(
424 + prices.basic_founder, prices.basic_std,
425 + "fixture makes this test vacuous if the two prices are equal"
426 + );
427 + assert!(
428 + resp.text
429 + .contains(&format!(r#"value="{}""#, prices.basic_founder)),
430 + "the Basic radio should open on the founder price"
431 + );
432 + // Both prices present, so <mnw-price-mode> can flip without a round trip.
433 + assert!(
434 + resp.text
435 + .contains(&format!(r#"data-price-std="{}""#, prices.basic_std)),
436 + "list price missing from the DOM, the toggle would have to fetch it"
437 + );
438 + }