From 1bd1bde8959b53ff3b00e0c421a08748205398f3 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 11 May 2026 15:31:29 -0500 Subject: [PATCH] =?UTF-8?q?v0.2.0:29=20=E2=80=94=20Tier-card=20cross-card?= =?UTF-8?q?=20horizontal=20alignment=20via=20subgrid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visually equivalent sections of each tier card (names, prices, first feature bullet, Select button) now line up horizontally across all visible tier cards. Cards with fewer / shorter sections get extra whitespace in the rows they don't fill — the explicit tradeoff the operator asked for, in service of a cleaner grid. - .tiers parent grid now declares 8 explicit row tracks. Each .tier is a subgrid that shares those rows. - Each section class (.tier-launch-meta, .tier-name, .tier-price- original, .tier-price, .tier-meta-block, .tier-description, .tier-features, .tier-select-btn) gets an explicit grid-row. Missing sections leave the row empty without breaking alignment. - Meta lines (duration, recurring, trial banner, trial flag) now wrapped in a single .tier-meta-block so they land in one row as a flex-column. - Launch-meta separated from featured_ribbon so each can occupy its own grid row independently (vs. the ribbon string previously embedding the meta div in-flow). - Side fix: .tier.has-launch swapped from overflow:hidden to clip-path polygon that preserves 20px above the card. The popular pill returns to top:-10px (above the card) without being clipped. Removed the v0.2.0:26-27 padding-top:36px workaround that pushed the pill inside. CSS + HTML composition only; public API JSON unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- licensing-service/src/api/buy_page.rs | 108 +++++++++++++++++++------- startos/versions/v0.2.0.ts | 16 +++- 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs index 407cbeb..93f59c1 100644 --- a/licensing-service/src/api/buy_page.rs +++ b/licensing-service/src/api/buy_page.rs @@ -281,9 +281,29 @@ h1 {{ }} .field .hint {{ font-size:12px; color:var(--ink-500); margin-top:5px; }} -/* Tier picker — shown when product has 2+ public policies. */ +/* Tier picker — shown when product has 2+ public policies. + Each tier card is a CSS subgrid that shares row tracks with the + parent .tiers grid. Effect: launch-meta, name, original-price, + price, meta-block, description, features, and button each occupy + the same vertical band across all visible cards, so visually + equivalent sections line up horizontally. Empty sections (e.g. + Creator has no struck-through original price) leave whitespace + in their row — the explicit tradeoff for clean cross-card + alignment. */ .tiers {{ display:grid; gap:14px; margin:0 0 28px; + /* 8-row template, one per logical section. The features row is + `1fr` so it absorbs extra vertical space (pushing the Select + button to the bottom). */ + grid-template-rows: + auto /* row 1: launch-meta */ + auto /* row 2: name */ + auto /* row 3: original-price (struck) */ + auto /* row 4: price */ + auto /* row 5: meta-block (duration + recurring + trial) */ + auto /* row 6: description */ + 1fr /* row 7: features (fills) */ + auto; /* row 8: button */ }} .tiers-2 {{ grid-template-columns:repeat(2, 1fr); }} .tiers-3 {{ grid-template-columns:repeat(3, 1fr); }} @@ -313,9 +333,27 @@ h1 {{ position:relative; background:var(--cream-50); border:1px solid var(--border-1); border-radius:12px; padding:22px 20px 20px; - display:flex; flex-direction:column; gap:10px; + /* Subgrid: each section (launch-meta, name, original-price, price, + meta-block, description, features, button) occupies the same row + in the parent .tiers grid as its counterpart in sibling cards. + This is how horizontal alignment across cards is achieved. + `row-gap:10px` keeps the visual rhythm between sections. */ + display:grid; grid-template-rows:subgrid; grid-row:1 / -1; + row-gap:10px; cursor:pointer; transition:all 150ms ease; }} +/* Explicit grid-row per section class — required because the picker + omits sections it has no content for (e.g. Creator has no + launch-meta or original-price line). Auto-flow would place the + first present child in row 1, breaking cross-card alignment. */ +.tier-launch-meta {{ grid-row:1; }} +.tier-name {{ grid-row:2; }} +.tier-price-original {{ grid-row:3; }} +.tier-price {{ grid-row:4; }} +.tier-meta-block {{ grid-row:5; display:flex; flex-direction:column; gap:4px; }} +.tier-description {{ grid-row:6; }} +.tier-features {{ grid-row:7; }} +.tier-select-btn {{ grid-row:8; }} .tier:hover {{ border-color:var(--gold-500); box-shadow:0 4px 12px rgba(14,31,51,0.08); @@ -337,22 +375,11 @@ h1 {{ white-space:nowrap; z-index:3; }} -/* When a tier carries BOTH the "most popular" pill and the launch - ribbon, the ribbon's `overflow:hidden` (which clips the ribbon's - off-card overhang) was also clipping the popular pill that sits 10px - above the card. Move the pill INSIDE the card top edge in that - specific combination so the pill stays visible — and push the - card's content padding down to leave room so it doesn't sit on top - of the "Limited: ..." meta line. */ -.tier.has-launch.highlighted {{ - padding-top:36px; -}} -.tier.has-launch.highlighted.selected {{ - padding-top:35px; /* compensate for thicker selected-border */ -}} -.tier.has-launch .tier-popular {{ - top:8px; -}} +/* Popular pill stays at top:-10px above the card universally. The + launch ribbon's right-side overhang is clipped via `clip-path` + below, which (unlike `overflow:hidden`) doesn't clip above the + card — so the popular pill remains visible even when the tier + has both the highlight and the launch ribbon. */ .tier-name {{ font-family:var(--font-display); font-weight:600; font-size:18px; color:var(--navy-950); letter-spacing:-0.01em; @@ -377,7 +404,14 @@ h1 {{ corner of any tier with an active featured discount. Plus the strike-through original-price line that renders ABOVE the discounted price. */ -.tier.has-launch {{ overflow:hidden; }} +/* Clip ONLY the right + bottom + left side of the card (so the + diagonal ribbon's overhang is hidden), while leaving 20px of + space ABOVE the card visible (so the "Most popular" pill at + top:-10px isn't clipped). `overflow:hidden` would clip in all + four directions and chop the popular pill. */ +.tier.has-launch {{ + clip-path: polygon(0 -20px, 100% -20px, 100% 100%, 0 100%); +}} .tier-launch-ribbon {{ position:absolute; top:14px; right:-44px; background:var(--gold-500); color:var(--navy-950); @@ -1082,7 +1116,15 @@ fn render_tier_picker( }; // Ribbon + slashed-original-price markup. Only emitted when // a featured discount actually applies. - let (featured_ribbon, original_price_html) = if let Some(code) = featured { + // Split the featured-discount artifact into three discrete + // pieces so each can land in its own grid row independently + // (vs. before, where the launch-ribbon string ALSO contained + // the launch-meta div as an in-flow element — that coupling + // made cross-card row alignment impossible). + // - featured_ribbon: absolutely-positioned diagonal banner + // - launch_meta_html: in-flow "Limited: X of Y remaining" + // - original_price_html: in-flow struck-through original + let (featured_ribbon, launch_meta_html, original_price_html) = if let Some(code) = featured { let tagline = if code.kind == "percent" { format!("{}% OFF", code.amount / 100) } else if code.kind == "free_license" { @@ -1093,7 +1135,7 @@ fn render_tier_picker( "LAUNCH SPECIAL".to_string() }; let remaining = code.max_uses.map(|m| (m - code.used_count).max(0)).unwrap_or(-1); - let remaining_html = if remaining > 0 { + let launch_meta = if remaining > 0 { format!( "
Limited: {} of {} remaining
", remaining, @@ -1104,10 +1146,10 @@ fn render_tier_picker( }; ( format!( - "
{}
{}", + "
{}
", html_escape(&tagline), - remaining_html, ), + launch_meta, format!( "
{}{}
", original_fmt, @@ -1115,7 +1157,7 @@ fn render_tier_picker( ), ) } else { - (String::new(), String::new()) + (String::new(), String::new(), String::new()) }; let description = p .metadata @@ -1307,21 +1349,29 @@ fn render_tier_picker( } else { classes.clone() }; + // Wrap the meta lines (duration + recurring cadence + trial + // banner + trial flag) into ONE block so the grid layout can + // place them as a single row. Subgrid sizes that row to the + // tallest meta block across all tiers — cards with fewer + // lines get whitespace below their meta, which is the + // explicit tradeoff for horizontal alignment. + let meta_block_html = format!( + "
{}{}{}{}
", + dur_html, recurring_meta, trial_banner, trial_meta + ); format!( - r#"
{popular_pill}{featured_ribbon}
{name}
{original_price_html}
{price_fmt}{price_unit}{cadence_suffix}
{dur_html}{recurring_meta}{trial_banner}{trial_meta}{description_html}{features_html}
"#, + r#"
{popular_pill}{featured_ribbon}{launch_meta_html}
{name}
{original_price_html}
{price_fmt}{price_unit}{cadence_suffix}
{meta_block_html}{description_html}{features_html}
"#, classes = classes, slug = slug_attr, popular_pill = popular_pill, featured_ribbon = featured_ribbon, + launch_meta_html = launch_meta_html, name = name, original_price_html = original_price_html, price_fmt = price_fmt, price_unit = price_unit, cadence_suffix = cadence_suffix, - dur_html = dur_html, - recurring_meta = recurring_meta, - trial_banner = trial_banner, - trial_meta = trial_meta, + meta_block_html = meta_block_html, description_html = description_html, features_html = features_html, ) diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts index 27c8dc3..d4cf5e5 100644 --- a/startos/versions/v0.2.0.ts +++ b/startos/versions/v0.2.0.ts @@ -58,6 +58,20 @@ const RELEASE_NOTES = [ // in RELEASE_NOTES above (the milestone). Subsequent revisions // append here. const ROUTINE_NOTES = [ + '0.2.0:29 — **Tier-card cross-card horizontal alignment via CSS subgrid.** Visually equivalent sections (names, prices, first feature bullet, Select button) now line up horizontally across all visible tier cards. Cards with fewer / shorter sections get extra whitespace in the rows they don\'t fill — the explicit tradeoff the operator asked for, in service of a cleaner grid.', + '', + '**How.** Each `.tier` card is now a CSS subgrid that shares row tracks with the parent `.tiers` grid. Eight named rows: launch-meta → name → original-price → price → meta-block → description → features (1fr) → button. Each section in the card emits with an explicit `grid-row`, so omitted sections (e.g. Creator has no struck-through original-price line) just leave the row empty while still preserving the alignment across siblings. The features row is `1fr` so it absorbs vertical slack, pinning the Select button to the bottom of every card.', + '', + '**Side fix: popular pill no longer clipped without the in-card hack.** Replaced `overflow:hidden` on `.tier.has-launch` (which was chopping the "MOST POPULAR" pill at top:-10px) with a `clip-path: polygon(0 -20px, 100% -20px, 100% 100%, 0 100%)`. Same effect for the launch ribbon overhang on the right, but leaves the 20px above the card visible so the popular pill survives. Removed the v0.2.0:26-27 `padding-top:36px` workaround that pushed pill inside the card; with subgrid alignment it\'s no longer needed.', + '', + '**Meta lines now wrapped in a single `.tier-meta-block`** (duration + recurring cadence + trial banner + trial flag). Previously each was its own `
` which made them impossible to place as one grid row. Now they\'re a flex-column wrapper that lands in row 5, with tier-cards that have fewer meta lines getting whitespace below their content.', + '', + '**Browser requirement.** CSS subgrid lands in Chrome 117+ (Aug 2023), Firefox 71+ (2019), Safari 16+ (Sept 2022). Should cover essentially every browser an operator points at by 2026; if a very old browser falls through, the cards still render — just with the old non-aligned look (subgrid degrades to its parent grid track config, which is benign).', + '', + '**Test count: 87** (unchanged — pure render-layer + CSS).', + '', + '**Upgrade path.** v0.2.0:28 → v0.2.0:29 is a drop-in. No schema, no SDK breaking change. Public `/v1/products//policies` JSON unchanged.', + '', '0.2.0:28 — **Settings cleanup, operator-name save fix, Licenses "Hide revoked" toggle.** Three small admin-UI changes.', '', '**Settings page intro card removed.** The "Operator-facing configuration. Display name, payment provider connections, …" preamble at the top of the Settings page was redundant with the page title + the section headers below; dropping it tightens the layout and gets the operator straight to the controls.', @@ -442,7 +456,7 @@ const ROUTINE_NOTES = [ ].join('\n\n') export const v0_2_0 = VersionInfo.of({ - version: '0.2.0:28', + version: '0.2.0:29', releaseNotes: { en_US: ROUTINE_NOTES }, // No on-disk transformation needed — v0.2.0:0 is a label change. // SQLite-level migrations live separately under