max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_0136sbU8F6i9WrcvA3wn4Lgk
19 files changed,
+1084 insertions,
-905 deletions
| @@ -252,7 +252,8 @@ | |||
| 252 | 252 | headline, | |
| 253 | 253 | verdict, | |
| 254 | 254 | crossover_note, | |
| 255 | - | scale: Scale::build(i.sales_per_month, crossover, verdict), | |
| 255 | + | sales_per_month: i.sales_per_month, | |
| 256 | + | crossover_sales: crossover, | |
| 256 | 257 | } | |
| 257 | 258 | } | |
| 258 | 259 | } | |
| @@ -270,80 +271,6 @@ | |||
| 270 | 271 | OtherAhead, | |
| 271 | 272 | } | |
| 272 | 273 | ||
| 273 | - | impl Verdict { | |
| 274 | - | /// CSS modifier for the headline, so the template branches on nothing. | |
| 275 | - | pub fn css_class(self) -> &'static str { | |
| 276 | - | match self { | |
| 277 | - | Verdict::MnwAhead => "verdict--ahead", | |
| 278 | - | Verdict::Even => "verdict--even", | |
| 279 | - | Verdict::OtherAheadForNow | Verdict::OtherAhead => "verdict--behind", | |
| 280 | - | } | |
| 281 | - | } | |
| 282 | - | } | |
| 283 | - | ||
| 284 | - | /// The sales axis under the numbers, marking where each platform wins. | |
| 285 | - | /// | |
| 286 | - | /// Rendered as one bar: a leading segment where the other platform costs less, | |
| 287 | - | /// the rest where MNW does, and a marker at the entered volume. All three are | |
| 288 | - | /// percentages of the bar so the template needs no units and no math. | |
| 289 | - | #[derive(Debug, Clone)] | |
| 290 | - | pub struct Scale { | |
| 291 | - | /// Width of the segment where the other platform wins, as a percentage. | |
| 292 | - | pub other_pct_width: String, | |
| 293 | - | /// Width of the segment where MNW wins, as a percentage. | |
| 294 | - | pub mnw_pct_width: String, | |
| 295 | - | /// Position of the "you are here" marker, as a percentage. | |
| 296 | - | pub marker_pct: String, | |
| 297 | - | /// Right-hand end of the axis, e.g. `160 sales`. | |
| 298 | - | pub max_label: String, | |
| 299 | - | /// Whether there is a crossover tick to draw at all. Kept alongside | |
| 300 | - | /// `crossover_label` so the template can branch without unwrapping. | |
| 301 | - | pub has_crossover: bool, | |
| 302 | - | /// Label under the crossover tick, `None` when there is no crossover. | |
| 303 | - | pub crossover_label: Option<String>, | |
| 304 | - | /// Position of the crossover tick, as a percentage. Empty with no tick. | |
| 305 | - | pub crossover_pct: String, | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | impl Scale { | |
| 309 | - | fn build(sales: f64, crossover: Option<f64>, verdict: Verdict) -> Self { | |
| 310 | - | // Show at least twice the interesting point so both regions are | |
| 311 | - | // visible, and never a degenerate zero-width axis. | |
| 312 | - | let interest = crossover.unwrap_or(0.0).max(sales); | |
| 313 | - | let max = (interest * 2.0).max(10.0).ceil().min(MAX_SALES * 2.0); | |
| 314 | - | let pct = |v: f64| format!("{:.4}", (v / max * 100.0).clamp(0.0, 100.0)); | |
| 315 | - | ||
| 316 | - | match crossover { | |
| 317 | - | Some(c) => { | |
| 318 | - | let split = (c / max * 100.0).clamp(0.0, 100.0); | |
| 319 | - | Self { | |
| 320 | - | other_pct_width: format!("{split:.4}"), | |
| 321 | - | mnw_pct_width: format!("{:.4}", 100.0 - split), | |
| 322 | - | marker_pct: pct(sales), | |
| 323 | - | max_label: format!("{} sales", fmt_count(max)), | |
| 324 | - | has_crossover: true, | |
| 325 | - | crossover_label: Some(format!("{} sales", fmt_count(c.ceil()))), | |
| 326 | - | crossover_pct: format!("{split:.4}"), | |
| 327 | - | } | |
| 328 | - | } | |
| 329 | - | // No crossover: one region spans the whole axis. Which one depends | |
| 330 | - | // on whether MNW is ahead everywhere or behind everywhere. | |
| 331 | - | None => { | |
| 332 | - | let mnw_everywhere = verdict == Verdict::MnwAhead || verdict == Verdict::Even; | |
| 333 | - | Self { | |
| 334 | - | other_pct_width: if mnw_everywhere { "0" } else { "100" }.to_string(), | |
| 335 | - | mnw_pct_width: if mnw_everywhere { "100" } else { "0" }.to_string(), | |
| 336 | - | marker_pct: pct(sales), | |
| 337 | - | max_label: format!("{} sales", fmt_count(max)), | |
| 338 | - | has_crossover: false, | |
| 339 | - | crossover_label: None, | |
| 340 | - | crossover_pct: String::new(), | |
| 341 | - | } | |
| 342 | - | } | |
| 343 | - | } | |
| 344 | - | } | |
| 345 | - | } | |
| 346 | - | ||
| 347 | 274 | /// Rendered calculator output, handed to the page and the HTMX partial. | |
| 348 | 275 | #[derive(Debug, Clone)] | |
| 349 | 276 | pub struct Outcome { | |
| @@ -362,7 +289,15 @@ | |||
| 362 | 289 | pub verdict: Verdict, | |
| 363 | 290 | /// Where the crossover sits, or why there isn't one. | |
| 364 | 291 | pub crossover_note: Option<String>, | |
| 365 | - | pub scale: Scale, | |
| 292 | + | /// The volume the dials were set to, so the panel can say where that sits | |
| 293 | + | /// against the crossover. Carried rather than recomputed: the reader typed | |
| 294 | + | /// it, `sanitize` rounded it, and a second rounding downstream is a second | |
| 295 | + | /// answer. | |
| 296 | + | pub sales_per_month: f64, | |
| 297 | + | /// Sales a month at which we become the cheaper place, exactly. `None` when | |
| 298 | + | /// our per-sale advantage is zero or negative, which is the case no volume | |
| 299 | + | /// closes. | |
| 300 | + | pub crossover_sales: Option<f64>, | |
| 366 | 301 | } | |
| 367 | 302 | ||
| 368 | 303 | /// Format dollars as `$1,234.56`, with the sign ahead of the `$`. | |
| @@ -505,10 +440,8 @@ | |||
| 505 | 440 | assert_eq!(out.verdict, Verdict::OtherAhead); | |
| 506 | 441 | let note = out.crossover_note.expect("no-crossover note"); | |
| 507 | 442 | assert!(note.contains("no amount of volume"), "note was: {note}"); | |
| 508 | - | // The whole axis belongs to the other platform. | |
| 509 | - | assert_eq!(out.scale.mnw_pct_width, "0"); | |
| 510 | - | assert_eq!(out.scale.other_pct_width, "100"); | |
| 511 | - | assert!(out.scale.crossover_label.is_none()); | |
| 443 | + | // No volume closes the gap, so there is no crossover to point at. | |
| 444 | + | assert!(out.crossover_sales.is_none()); | |
| 512 | 445 | } | |
| 513 | 446 | ||
| 514 | 447 | /// A free tier has nothing to repay, so MNW leads from the first sale and | |
| @@ -524,7 +457,7 @@ | |||
| 524 | 457 | }); | |
| 525 | 458 | assert_eq!(out.verdict, Verdict::MnwAhead); | |
| 526 | 459 | assert!(out.crossover_note.is_none()); | |
| 527 | - | assert_eq!(out.scale.mnw_pct_width, "100"); | |
| 460 | + | assert!(out.crossover_sales.is_none()); | |
| 528 | 461 | } | |
| 529 | 462 | ||
| 530 | 463 | /// Zero sales is a real answer, not a placeholder: the subscription still | |
| @@ -605,10 +538,16 @@ | |||
| 605 | 538 | approx(inf.other_pct, c.defaults.other_pct, "NaN cut"); | |
| 606 | 539 | } | |
| 607 | 540 | ||
| 608 | - | /// The bar's two segments always tile the axis exactly, and the marker | |
| 609 | - | /// stays inside it, at every dial position the inputs allow. | |
| 541 | + | /// A crossover is a count of sales, so it is positive and finite wherever | |
| 542 | + | /// there is one, at every dial position the inputs allow. | |
| 543 | + | /// | |
| 544 | + | /// This replaces `scale_segments_tile_the_axis`, which checked that two | |
| 545 | + | /// hand-computed bar widths summed to 100. The widths went with the | |
| 546 | + | /// template that positioned them; the property worth holding is the one the | |
| 547 | + | /// panel now states, since a meter drawn against a zero or negative total | |
| 548 | + | /// says nothing a reader can act on. | |
| 610 | 549 | #[test] | |
| 611 | - | fn scale_segments_tile_the_axis() { | |
| 550 | + | fn a_crossover_is_a_positive_count_of_sales_wherever_there_is_one() { | |
| 612 | 551 | let c = loaded(); | |
| 613 | 552 | for sales in [0.0, 1.0, 7.0, 40.0, 5_000.0, MAX_SALES] { | |
| 614 | 553 | for price in [0.0, 1.0, 25.0, MAX_ITEM_PRICE] { | |
| @@ -620,14 +559,17 @@ | |||
| 620 | 559 | other_pct: pct, | |
| 621 | 560 | other_per_sale: 0.30, | |
| 622 | 561 | }); | |
| 623 | - | let a: f64 = out.scale.other_pct_width.parse().unwrap(); | |
| 624 | - | let b: f64 = out.scale.mnw_pct_width.parse().unwrap(); | |
| 625 | - | assert!( | |
| 626 | - | (a + b - 100.0).abs() < 0.01, | |
| 627 | - | "segments {a} + {b} at price {price}, {sales} sales, pct {pct}" | |
| 562 | + | if let Some(crossover) = out.crossover_sales { | |
| 563 | + | assert!( | |
| 564 | + | crossover.is_finite() && crossover > 0.0, | |
| 565 | + | "crossover {crossover} at price {price}, {sales} sales, pct {pct}" | |
| 566 | + | ); | |
| 567 | + | } | |
| 568 | + | approx( | |
| 569 | + | out.sales_per_month, | |
| 570 | + | sales, | |
| 571 | + | "the panel is told a volume the reader did not set", | |
| 628 | 572 | ); | |
| 629 | - | let m: f64 = out.scale.marker_pct.parse().unwrap(); | |
| 630 | - | assert!((0.0..=100.0).contains(&m), "marker {m} out of the axis"); | |
| 631 | 573 | } | |
| 632 | 574 | } | |
| 633 | 575 | } |
| @@ -594,6 +594,13 @@ | |||
| 594 | 594 | .fold(csrf_routes, |routes, (path, described)| { | |
| 595 | 595 | routes.nest_service(path, described) | |
| 596 | 596 | }); | |
| 597 | + | // The public half of the description layer. Same envelope, different | |
| 598 | + | // factory: these screens resolve no session. See `quasi::public_mounts`. | |
| 599 | + | let csrf_routes = quasi::public_mounts(state) | |
| 600 | + | .into_iter() | |
| 601 | + | .fold(csrf_routes, |routes, (path, described)| { | |
| 602 | + | routes.nest_service(path, described) | |
| 603 | + | }); | |
| 597 | 604 | let csrf_routes = csrf_routes.finalize(); | |
| 598 | 605 | let app = Router::new() | |
| 599 | 606 | .merge(page_routes(state.config.rate_limits)) |
| @@ -44,8 +44,18 @@ | |||
| 44 | 44 | ||
| 45 | 45 | fn parts() -> &'static quasi_webview::Parts { | |
| 46 | 46 | static PARTS: OnceLock<quasi_webview::Parts> = OnceLock::new(); | |
| 47 | - | PARTS.get_or_init(|| { | |
| 48 | - | Shell::under("/static") | |
| 47 | + | PARTS.get_or_init(|| described().parts()) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | /// The shell every document on this site is drawn in, described or templated. | |
| 51 | + | /// | |
| 52 | + | /// `base.html` takes it as [`parts`] and assembles the rest itself; a screen | |
| 53 | + | /// that owns its whole document (`crate::quasi::pricing`) takes the `Shell` and | |
| 54 | + | /// hands it to a renderer. One builder either way, which is the whole point: | |
| 55 | + | /// two heads written by two hands drift in ways nothing catches. | |
| 56 | + | #[must_use] | |
| 57 | + | pub fn described() -> Shell { | |
| 58 | + | Shell::under("/static") | |
| 49 | 59 | // Earlier in the list = lower priority, and `makeover` is prepended | |
| 50 | 60 | // by the renderer. A layer's position is fixed where its name is | |
| 51 | 61 | // FIRST seen, so without the statement the generated sheets would | |
| @@ -116,8 +126,40 @@ | |||
| 116 | 126 | <script src=\"/static/upload.js?v={V}\"></script>\ | |
| 117 | 127 | <script type=\"module\" src=\"/static/dist-{V}/core/index.js\"></script>" | |
| 118 | 128 | )) | |
| 119 | - | .parts() | |
| 120 | - | }) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | /// The link that jumps a keyboard reader past everything to the content. | |
| 132 | + | /// | |
| 133 | + | /// Takes the id it points at, because a described screen's first region | |
| 134 | + | /// publishes its own slot id and `base.html`'s `<main>` is called | |
| 135 | + | /// `main-content`. One spelling of the markup either way: this is an a11y | |
| 136 | + | /// affordance the site owes on every page, and a second copy of it is a copy | |
| 137 | + | /// that goes stale. | |
| 138 | + | #[must_use] | |
| 139 | + | pub fn skip_link(target: &str) -> String { | |
| 140 | + | format!("<a href=\"#{target}\" class=\"skip-to-main\">Skip to main content</a>") | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// What every page on this site ends with: the toast container and the classic | |
| 144 | + | /// script shims the `data-action` dispatcher resolves through. | |
| 145 | + | /// | |
| 146 | + | /// Called by `base.html` for the templated pages and handed to | |
| 147 | + | /// [`quasi_webview::Shell::with_body_last`] by a screen that owns its own | |
| 148 | + | /// document, so the tail is written once. Markup no description will ever name | |
| 149 | + | /// -- a script tag, and a container another script writes into. | |
| 150 | + | #[must_use] | |
| 151 | + | pub fn body_last() -> &'static str { | |
| 152 | + | concat!( | |
| 153 | + | "<div id=\"notifications\" class=\"toast-container\" role=\"alert\" aria-live=\"polite\"></div>", | |
| 154 | + | "<script src=\"/static/actions-pages.js?v=0701\"></script>", | |
| 155 | + | "<script src=\"/static/actions-partials.js?v=0701\"></script>", | |
| 156 | + | "<script src=\"/static/actions-tabs.js?v=0701\"></script>", | |
| 157 | + | "<script src=\"/static/actions-dashboards.js?v=0701\"></script>", | |
| 158 | + | "<script src=\"/static/synckit-billing.js?v=0620\"></script>", | |
| 159 | + | "<script src=\"/static/synckit-tabs.js?v=0623\"></script>", | |
| 160 | + | "<script src=\"/static/whats_new.js?v=0522\"></script>", | |
| 161 | + | "<script src=\"/static/markdown-editor.js?v=0820\"></script>", | |
| 162 | + | ) | |
| 121 | 163 | } | |
| 122 | 164 | ||
| 123 | 165 | /// `<!doctype>` through the head's contents, without `</head>`. |
| @@ -6462,16 +6462,12 @@ | |||
| 6462 | 6462 | PRICING CALCULATOR | |
| 6463 | 6463 | =========================================== */ | |
| 6464 | 6464 | ||
| 6465 | - | .pricing-page { | |
| 6466 | - | max-width: 800px; | |
| 6467 | - | } | |
| 6468 | - | ||
| 6469 | - | .pricing-input-wrap { | |
| 6470 | - | display: flex; | |
| 6471 | - | align-items: baseline; | |
| 6472 | - | justify-content: center; | |
| 6473 | - | gap: var(--gap-bound); | |
| 6474 | - | } | |
| 6465 | + | /* The calculator itself is a described screen since `1e35bc8a` | |
| 6466 | + | (`crate::quasi::pricing`), so it is drawn in makeover's own classes and every | |
| 6467 | + | rule this section held for it went with `pages/pricing.html`: the dials, the | |
| 6468 | + | tier cards, the list/founder toggle, the verdict, the results table and the | |
| 6469 | + | hand-drawn crossover bar. What is left is the one rule another template still | |
| 6470 | + | matches. */ | |
| 6475 | 6471 | ||
| 6476 | 6472 | .pricing-currency { | |
| 6477 | 6473 | font-family: var(--font-display); | |
| @@ -6479,272 +6475,6 @@ | |||
| 6479 | 6475 | opacity: 0.5; | |
| 6480 | 6476 | } | |
| 6481 | 6477 | ||
| 6482 | - | .pricing-input { | |
| 6483 | - | font-family: var(--font-display); | |
| 6484 | - | font-size: var(--text-display); | |
| 6485 | - | width: 5ch; | |
| 6486 | - | text-align: center; | |
| 6487 | - | background: transparent; | |
| 6488 | - | border: none; | |
| 6489 | - | border-bottom: 2px solid var(--border); | |
| 6490 | - | color: var(--content); | |
| 6491 | - | padding: var(--gap-bound) 0; | |
| 6492 | - | -moz-appearance: textfield; | |
| 6493 | - | } | |
| 6494 | - | ||
| 6495 | - | .pricing-input::-webkit-inner-spin-button, | |
| 6496 | - | .pricing-input::-webkit-outer-spin-button { | |
| 6497 | - | -webkit-appearance: none; | |
| 6498 | - | margin: 0; | |
| 6499 | - | } | |
| 6500 | - | ||
| 6501 | - | .pricing-input:focus { | |
| 6502 | - | outline: none; | |
| 6503 | - | border-bottom-color: var(--action); | |
| 6504 | - | } | |
| 6505 | - | ||
| 6506 | - | .pricing-period { | |
| 6507 | - | font-family: var(--font-mono); | |
| 6508 | - | font-size: var(--text-body); | |
| 6509 | - | opacity: 0.5; | |
| 6510 | - | } | |
| 6511 | - | ||
| 6512 | - | /* <mnw-price-mode> is a custom element (default display:inline); it wraps the | |
| 6513 | - | toggle and the tier grid, so it has to lay out as a block. */ | |
| 6514 | - | mnw-price-mode { | |
| 6515 | - | display: block; | |
| 6516 | - | } | |
| 6517 | - | ||
| 6518 | - | /* List-vs-founder toggle above the tier cards, rendered only while the founder | |
| 6519 | - | window is open. Segmented control rather than a checkbox: both options are | |
| 6520 | - | real prices the visitor can be shown, and neither is an "off" state. Follows | |
| 6521 | - | .tier-option's pattern of hiding the radio and styling the label off | |
| 6522 | - | :has(:checked), so the selection needs no JS class. */ | |
| 6523 | - | .price-mode { | |
| 6524 | - | display: inline-flex; | |
| 6525 | - | gap: 0; | |
| 6526 | - | margin-bottom: var(--gap-section); | |
| 6527 | - | border: 1px solid var(--border); | |
| 6528 | - | border-radius: var(--radius-control); | |
| 6529 | - | overflow: hidden; | |
| 6530 | - | } | |
| 6531 | - | ||
| 6532 | - | .price-mode-option { | |
| 6533 | - | cursor: pointer; | |
| 6534 | - | font-family: var(--font-mono); | |
| 6535 | - | font-size: var(--text-note); | |
| 6536 | - | padding: var(--gap-bound) var(--gap-section); | |
| 6537 | - | transition: background-color 0.15s ease; | |
| 6538 | - | } | |
| 6539 | - | ||
| 6540 | - | .price-mode-option input[type="radio"] { | |
| 6541 | - | position: absolute; | |
| 6542 | - | opacity: 0; | |
| 6543 | - | pointer-events: none; | |
| 6544 | - | } | |
| 6545 | - | ||
| 6546 | - | .price-mode-option:has(input[type="radio"]:checked) { | |
| 6547 | - | background: var(--action); | |
| 6548 | - | color: var(--content-on-action); | |
| 6549 | - | } | |
| 6550 | - | ||
| 6551 | - | .price-mode-option:focus-within { | |
| 6552 | - | outline: 2px solid var(--focus-ring); | |
| 6553 | - | outline-offset: -2px; | |
| 6554 | - | } | |
| 6555 | - | ||
| 6556 | - | .tier-selector { | |
| 6557 | - | display: grid; | |
| 6558 | - | grid-template-columns: 1fr 1fr; | |
| 6559 | - | gap: var(--gap-section); | |
| 6560 | - | text-align: left; | |
| 6561 | - | } | |
| 6562 | - | ||
| 6563 | - | .tier-option { | |
| 6564 | - | cursor: pointer; | |
| 6565 | - | transition: border-color 0.15s ease; | |
| 6566 | - | } | |
| 6567 | - | ||
| 6568 | - | .tier-option input[type="radio"] { | |
| 6569 | - | position: absolute; | |
| 6570 | - | opacity: 0; | |
| 6571 | - | pointer-events: none; | |
| 6572 | - | } | |
| 6573 | - | ||
| 6574 | - | .tier-option:focus-within { | |
| 6575 | - | outline: 2px solid var(--focus-ring); | |
| 6576 | - | outline-offset: 2px; | |
| 6577 | - | } | |
| 6578 | - | ||
| 6579 | - | /* Selected tier, driven purely by the checked radio (no JS toggling a | |
| 6580 | - | class). The `.is-selected` alias is retained for any server-rendered use. */ | |
| 6581 | - | .tier-option.is-selected, | |
| 6582 | - | .tier-option:has(input[type="radio"]:checked) { | |
| 6583 | - | border-color: var(--action); | |
| 6584 | - | border-width: 2px; | |
| 6585 | - | padding: calc(var(--gap-section) - 1px); | |
| 6586 | - | } | |
| 6587 | - | ||
| 6588 | - | /* Two dials side by side (price + volume, then the other platform's two). | |
| 6589 | - | Stacks on narrow screens via the mobile block below. */ | |
| 6590 | - | .calc-dials { | |
| 6591 | - | display: grid; | |
| 6592 | - | grid-template-columns: 1fr 1fr; | |
| 6593 | - | gap: var(--gap-section); | |
| 6594 | - | align-items: end; | |
| 6595 | - | } | |
| 6596 | - | ||
| 6597 | - | .calc-dial { | |
| 6598 | - | display: flex; | |
| 6599 | - | flex-direction: column; | |
| 6600 | - | align-items: center; | |
| 6601 | - | gap: var(--gap-bound); | |
| 6602 | - | } | |
| 6603 | - | ||
| 6604 | - | .calc-dial-label { | |
| 6605 | - | font-family: var(--font-mono); | |
| 6606 | - | font-size: var(--text-note); | |
| 6607 | - | opacity: 0.7; | |
| 6608 | - | } | |
| 6609 | - | ||
| 6610 | - | /* Who comes out ahead at the current dials. The three states are the only | |
| 6611 | - | place the calculator uses color to make a claim, so they carry the same | |
| 6612 | - | weight in both directions: losing is stated as plainly as winning. */ | |
| 6613 | - | .calc-verdict { | |
| 6614 | - | font-family: var(--font-display); | |
| 6615 | - | font-size: var(--text-title); | |
| 6616 | - | text-align: center; | |
| 6617 | - | padding: var(--gap-pane); | |
| 6618 | - | background: var(--surface-overlay); | |
| 6619 | - | border: 1px solid var(--border); | |
| 6620 | - | border-left-width: 3px; | |
| 6621 | - | } | |
| 6622 | - | ||
| 6623 | - | .verdict--ahead { | |
| 6624 | - | border-left-color: var(--success); | |
| 6625 | - | } | |
| 6626 | - | ||
| 6627 | - | .verdict--even { | |
| 6628 | - | border-left-color: var(--border); | |
| 6629 | - | } | |
| 6630 | - | ||
| 6631 | - | .verdict--behind { | |
| 6632 | - | border-left-color: var(--warning); | |
| 6633 | - | } | |
| 6634 | - | ||
| 6635 | - | .calc-table th[scope="row"] { | |
| 6636 | - | text-align: left; | |
| 6637 | - | font-weight: 400; | |
| 6638 | - | opacity: 0.7; | |
| 6639 | - | } | |
| 6640 | - | ||
| 6641 | - | .calc-table .calc-gross, | |
| 6642 | - | .calc-keep-row td { | |
| 6643 | - | font-family: var(--font-mono); | |
| 6644 | - | } | |
| 6645 | - | ||
| 6646 | - | .calc-keep-row td { | |
| 6647 | - | font-size: var(--text-title); | |
| 6648 | - | } | |
| 6649 | - | ||
| 6650 | - | /* The win-region bar. Two segments tile the full width; the server sends both | |
| 6651 | - | widths as percentages, so nothing here is computed in the browser. */ | |
| 6652 | - | .calc-scale { | |
| 6653 | - | display: flex; | |
| 6654 | - | flex-direction: column; | |
| 6655 | - | gap: var(--gap-bound); | |
| 6656 | - | } | |
| 6657 | - | ||
| 6658 | - | .calc-bar { | |
| 6659 | - | position: relative; | |
| 6660 | - | display: flex; | |
| 6661 | - | height: 1.5rem; | |
| 6662 | - | border: 1px solid var(--border); | |
| 6663 | - | overflow: hidden; | |
| 6664 | - | } | |
| 6665 | - | ||
| 6666 | - | .calc-bar-seg { | |
| 6667 | - | height: 100%; | |
| 6668 | - | } | |
| 6669 | - | ||
| 6670 | - | .calc-bar-seg--other { | |
| 6671 | - | background: var(--warning); | |
| 6672 | - | opacity: 0.35; | |
| 6673 | - | } | |
| 6674 | - | ||
| 6675 | - | .calc-bar-seg--mnw { | |
| 6676 | - | background: var(--success); | |
| 6677 | - | opacity: 0.35; | |
| 6678 | - | } | |
| 6679 | - | ||
| 6680 | - | /* "You are here" on the sales axis. */ | |
| 6681 | - | .calc-marker { | |
| 6682 | - | position: absolute; | |
| 6683 | - | top: 0; | |
| 6684 | - | bottom: 0; | |
| 6685 | - | width: 2px; | |
| 6686 | - | margin-left: -1px; | |
| 6687 | - | background: var(--content); | |
| 6688 | - | } | |
| 6689 | - | ||
| 6690 | - | .calc-crossover { | |
| 6691 | - | position: absolute; | |
| 6692 | - | top: 0; | |
| 6693 | - | bottom: 0; | |
| 6694 | - | width: 1px; | |
| 6695 | - | background: var(--content); | |
| 6696 | - | opacity: 0.4; | |
| 6697 | - | } | |
| 6698 | - | ||
| 6699 | - | .calc-axis { | |
| 6700 | - | display: flex; | |
| 6701 | - | justify-content: space-between; | |
| 6702 | - | gap: var(--gap-bound); | |
| 6703 | - | font-family: var(--font-mono); | |
| 6704 | - | font-size: var(--text-fine); | |
| 6705 | - | opacity: 0.6; | |
| 6706 | - | } | |
| 6707 | - | ||
| 6708 | - | .calc-axis-crossover { | |
| 6709 | - | opacity: 0.9; | |
| 6710 | - | } | |
| 6711 | - | ||
| 6712 | - | .calc-legend { | |
| 6713 | - | display: flex; | |
| 6714 | - | flex-wrap: wrap; | |
| 6715 | - | gap: var(--gap-section); | |
| 6716 | - | font-size: var(--text-fine); | |
| 6717 | - | opacity: 0.7; | |
| 6718 | - | } | |
| 6719 | - | ||
| 6720 | - | .calc-legend-item::before { | |
| 6721 | - | content: ""; | |
| 6722 | - | display: inline-block; | |
| 6723 | - | width: 0.75rem; | |
| 6724 | - | height: 0.75rem; | |
| 6725 | - | margin-right: var(--gap-bound); | |
| 6726 | - | vertical-align: baseline; | |
| 6727 | - | } | |
| 6728 | - | ||
| 6729 | - | .calc-legend-item--other::before { | |
| 6730 | - | background: var(--warning); | |
| 6731 | - | opacity: 0.35; | |
| 6732 | - | } | |
| 6733 | - | ||
| 6734 | - | .calc-legend-item--mnw::before { | |
| 6735 | - | background: var(--success); | |
| 6736 | - | opacity: 0.35; | |
| 6737 | - | } | |
| 6738 | - | ||
| 6739 | - | .calc-note { | |
| 6740 | - | font-family: var(--font-mono); | |
| 6741 | - | font-size: var(--text-note); | |
| 6742 | - | padding: var(--gap-section); | |
| 6743 | - | background: var(--surface-overlay); | |
| 6744 | - | border-left: 3px solid var(--border); | |
| 6745 | - | line-height: 1.6; | |
| 6746 | - | } | |
| 6747 | - | ||
| 6748 | 6478 | /* =========================================== | |
| 6749 | 6479 | DOCUMENTATION PAGES | |
| 6750 | 6480 | =========================================== */ |
| @@ -29,7 +29,7 @@ | |||
| 29 | 29 | {% block head %}{% endblock %} | |
| 30 | 30 | </head> | |
| 31 | 31 | <body{{ crate::shell::body_attrs()|safe }}{% block body_attrs %}{% endblock %}> | |
| 32 | - | <a href="#main-content" class="skip-to-main">Skip to main content</a> | |
| 32 | + | {{ crate::shell::skip_link("main-content")|safe }} | |
| 33 | 33 | <main id="main-content"> | |
| 34 | 34 | {% block content %}{% endblock %} | |
| 35 | 35 | </main> | |
| @@ -52,26 +52,11 @@ | |||
| 52 | 52 | <p>© 2026 Make Creative, LLC</p> | |
| 53 | 53 | </footer> | |
| 54 | 54 | ||
| 55 | - | <!-- Toast notification container --> | |
| 56 | - | <div id="notifications" class="toast-container" role="alert" aria-live="polite"></div> | |
| 57 | - | ||
| 58 | - | <!-- Delegated on* handlers, extracted from inline attributes so the CSP can | |
| 59 | - | drop script-src 'unsafe-inline'. The data-action dispatcher + core | |
| 60 | - | primitives now live in the typed core module (frontend/src/core), | |
| 61 | - | loaded as an ES module in <head> via crate::shell; these classic | |
| 62 | - | shims resolve through its registry / window bridge. --> | |
| 63 | - | <script src="/static/actions-pages.js?v=0701"></script> | |
| 64 | - | <script src="/static/actions-partials.js?v=0701"></script> | |
| 65 | - | <script src="/static/actions-tabs.js?v=0701"></script> | |
| 66 | - | <script src="/static/actions-dashboards.js?v=0701"></script> | |
| 67 | - | <script src="/static/synckit-billing.js?v=0620"></script> | |
| 68 | - | <script src="/static/synckit-tabs.js?v=0623"></script> | |
| 69 | - | <script src="/static/whats_new.js?v=0522"></script> | |
| 70 | - | {# Binds the described markdown field's Write/Preview chrome wherever one | |
| 71 | - | appears. Loaded here rather than beside each of the four surfaces because | |
| 72 | - | three of them arrive inside HTMX-swapped partials, and the binder rescans | |
| 73 | - | on settle. #} | |
| 74 | - | <script src="/static/markdown-editor.js?v=0820"></script> | |
| 55 | + | {# The toast container and the classic `data-action` shims, emitted from | |
| 56 | + | `crate::shell` rather than written here: a screen that owns its whole | |
| 57 | + | document (`crate::quasi::pricing`) owes the same tail, and two copies of | |
| 58 | + | it drift. #} | |
| 59 | + | {{ crate::shell::body_last()|safe }} | |
| 75 | 60 | {% block scripts %}{% endblock %} | |
| 76 | 61 | </body> | |
| 77 | 62 | </html> |