v0.2.0:29 — Tier-card cross-card horizontal alignment via subgrid
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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!(
|
||||
"<div class=\"tier-launch-meta\">Limited: {} of {} remaining</div>",
|
||||
remaining,
|
||||
@@ -1104,10 +1146,10 @@ fn render_tier_picker(
|
||||
};
|
||||
(
|
||||
format!(
|
||||
"<div class=\"tier-launch-ribbon\">{}</div>{}",
|
||||
"<div class=\"tier-launch-ribbon\">{}</div>",
|
||||
html_escape(&tagline),
|
||||
remaining_html,
|
||||
),
|
||||
launch_meta,
|
||||
format!(
|
||||
"<div class=\"tier-price-original\">{}<span class=\"tier-price-original-unit\">{}</span></div>",
|
||||
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!(
|
||||
"<div class=\"tier-meta-block\">{}{}{}{}</div>",
|
||||
dur_html, recurring_meta, trial_banner, trial_meta
|
||||
);
|
||||
format!(
|
||||
r#"<div class="{classes}" data-policy-slug="{slug}">{popular_pill}{featured_ribbon}<div class="tier-name">{name}</div>{original_price_html}<div class="tier-price">{price_fmt}<span class="tier-price-unit">{price_unit}{cadence_suffix}</span></div>{dur_html}{recurring_meta}{trial_banner}{trial_meta}{description_html}{features_html}<button type="button" class="tier-select-btn">Select</button></div>"#,
|
||||
r#"<div class="{classes}" data-policy-slug="{slug}">{popular_pill}{featured_ribbon}{launch_meta_html}<div class="tier-name">{name}</div>{original_price_html}<div class="tier-price">{price_fmt}<span class="tier-price-unit">{price_unit}{cadence_suffix}</span></div>{meta_block_html}{description_html}{features_html}<button type="button" class="tier-select-btn">Select</button></div>"#,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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 `<div class="tier-meta">` 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/<slug>/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
|
||||
|
||||
Reference in New Issue
Block a user