v0.2.0:27 — Single tier-features ul; popular pill spacing fix
Two remaining buy-page issues from :26: - Tier-card feature list. Stop fighting the two-<ul> boundary with margin tweaks. Build ONE <ul class="tier-features"> server-side containing marketing bullets and entitlements in the operator- controlled order. Both groups render with identical ✓ + li styling, visually indistinguishable to the buyer. No list boundary = no gap. - "MOST POPULAR" + "Limited: ..." collision. The :26 fix moved the popular pill to top:8px (inside the card) for has-launch tiers, but that landed it on top of the launch-meta line. Push the card content down via padding-top:36px on .tier.has-launch.highlighted (35px when also .selected to compensate for the thicker border). CSS + HTML composition only; no schema, API, or SDK change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -341,7 +341,15 @@ h1 {{
|
|||||||
ribbon, the ribbon's `overflow:hidden` (which clips the ribbon's
|
ribbon, the ribbon's `overflow:hidden` (which clips the ribbon's
|
||||||
off-card overhang) was also clipping the popular pill that sits 10px
|
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
|
above the card. Move the pill INSIDE the card top edge in that
|
||||||
specific combination so the pill stays visible. */
|
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 {{
|
.tier.has-launch .tier-popular {{
|
||||||
top:8px;
|
top:8px;
|
||||||
}}
|
}}
|
||||||
@@ -391,24 +399,21 @@ h1 {{
|
|||||||
.tier-price-original-unit {{
|
.tier-price-original-unit {{
|
||||||
font-size:11.5px; margin-left:4px; color:var(--ink-500);
|
font-size:11.5px; margin-left:4px; color:var(--ink-500);
|
||||||
}}
|
}}
|
||||||
.tier-entitlements, .tier-bullets {{
|
/* Single merged feature list — entitlements and marketing bullets
|
||||||
|
render as one <ul> server-side so there's no list-boundary gap to
|
||||||
|
fight with CSS. Order is operator-controlled via
|
||||||
|
marketing_bullets_position. */
|
||||||
|
.tier-features {{
|
||||||
list-style:none; padding:0; margin:6px 0 0;
|
list-style:none; padding:0; margin:6px 0 0;
|
||||||
font-size:13px; color:var(--ink-700);
|
font-size:13px; color:var(--ink-700);
|
||||||
}}
|
}}
|
||||||
.tier-entitlements li, .tier-bullets li {{
|
.tier-features li {{
|
||||||
padding:3px 0 3px 18px; position:relative;
|
padding:3px 0 3px 18px; position:relative;
|
||||||
}}
|
}}
|
||||||
.tier-entitlements li::before, .tier-bullets li::before {{
|
.tier-features li::before {{
|
||||||
content:'✓'; position:absolute; left:0; top:3px;
|
content:'✓'; position:absolute; left:0; top:3px;
|
||||||
color:var(--gold-700); font-weight:700;
|
color:var(--gold-700); font-weight:700;
|
||||||
}}
|
}}
|
||||||
/* Marketing bullets and entitlements should read as ONE coherent
|
|
||||||
feature list regardless of which one renders first. Zero margin-top
|
|
||||||
here so the gap between the two lists matches the within-list gap
|
|
||||||
(each li already contributes 3px of top + bottom padding, so 6px
|
|
||||||
total between consecutive lines either way). */
|
|
||||||
.tier-bullets + .tier-entitlements {{ margin-top:0; }}
|
|
||||||
.tier-entitlements + .tier-bullets {{ margin-top:0; }}
|
|
||||||
.tier-select-btn {{
|
.tier-select-btn {{
|
||||||
margin-top:auto;
|
margin-top:auto;
|
||||||
padding:8px 12px;
|
padding:8px 12px;
|
||||||
@@ -1148,23 +1153,21 @@ fn render_tier_picker(
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s == "below")
|
.map(|s| s == "below")
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let marketing_html = p
|
// Marketing-bullet items (just the <li>s — we'll merge them
|
||||||
|
// with entitlement items into a single <ul> below so the two
|
||||||
|
// groups read as ONE continuous feature ladder with zero
|
||||||
|
// boundary artifact between them).
|
||||||
|
let marketing_lis: Vec<String> = p
|
||||||
.metadata
|
.metadata
|
||||||
.get("marketing_bullets")
|
.get("marketing_bullets")
|
||||||
.and_then(|v| v.as_array())
|
.and_then(|v| v.as_array())
|
||||||
.map(|arr| {
|
.map(|arr| {
|
||||||
let lis: Vec<String> = arr
|
arr.iter()
|
||||||
.iter()
|
|
||||||
.filter_map(|v| v.as_str())
|
.filter_map(|v| v.as_str())
|
||||||
.map(|s| s.trim())
|
.map(|s| s.trim())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| format!("<li>{}</li>", html_escape(s)))
|
.map(|s| format!("<li>{}</li>", html_escape(s)))
|
||||||
.collect();
|
.collect()
|
||||||
if lis.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("<ul class=\"tier-bullets\">{}</ul>", lis.join(""))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
// raw slug if the catalog is empty or the slug isn't in
|
// raw slug if the catalog is empty or the slug isn't in
|
||||||
@@ -1186,11 +1189,13 @@ fn render_tier_picker(
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|s| !hidden_on_buy.contains(s.as_str()))
|
.filter(|s| !hidden_on_buy.contains(s.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
let entitlements_html = if visible_entitlements.is_empty() {
|
// Entitlement <li>s. Same format as marketing items so the
|
||||||
String::new()
|
// merged feature list looks uniform — visual distinction
|
||||||
} else {
|
// between an "entitlement" and a "marketing bullet" is
|
||||||
|
// intentionally invisible to the buyer.
|
||||||
|
let entitlement_lis: Vec<String> = {
|
||||||
let catalog = product.entitlements_catalog.as_deref().unwrap_or(&[]);
|
let catalog = product.entitlements_catalog.as_deref().unwrap_or(&[]);
|
||||||
let lis: Vec<String> = visible_entitlements
|
visible_entitlements
|
||||||
.iter()
|
.iter()
|
||||||
.map(|slug| {
|
.map(|slug| {
|
||||||
let entry = catalog.iter().find(|e| &e.slug == *slug);
|
let entry = catalog.iter().find(|e| &e.slug == *slug);
|
||||||
@@ -1208,8 +1213,26 @@ fn render_tier_picker(
|
|||||||
html_escape(display),
|
html_escape(display),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect()
|
||||||
format!("<ul class=\"tier-entitlements\">{}</ul>", lis.join(""))
|
};
|
||||||
|
// Merge into a SINGLE <ul class="tier-features"> so there's
|
||||||
|
// no list-boundary gap to fight with CSS. Order respects the
|
||||||
|
// operator's marketing_bullets_position metadata.
|
||||||
|
let merged_lis: Vec<String> = if bullets_below {
|
||||||
|
entitlement_lis
|
||||||
|
.into_iter()
|
||||||
|
.chain(marketing_lis.into_iter())
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
marketing_lis
|
||||||
|
.into_iter()
|
||||||
|
.chain(entitlement_lis.into_iter())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let features_html = if merged_lis.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("<ul class=\"tier-features\">{}</ul>", merged_lis.join(""))
|
||||||
};
|
};
|
||||||
let dur_html = if p.duration_seconds > 0 {
|
let dur_html = if p.duration_seconds > 0 {
|
||||||
let days = p.duration_seconds / 86_400;
|
let days = p.duration_seconds / 86_400;
|
||||||
@@ -1284,14 +1307,8 @@ fn render_tier_picker(
|
|||||||
} else {
|
} else {
|
||||||
classes.clone()
|
classes.clone()
|
||||||
};
|
};
|
||||||
// Operator-controlled order: above (default) or below.
|
|
||||||
let (first_block, second_block) = if bullets_below {
|
|
||||||
(&entitlements_html, &marketing_html)
|
|
||||||
} else {
|
|
||||||
(&marketing_html, &entitlements_html)
|
|
||||||
};
|
|
||||||
format!(
|
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}{first_block}{second_block}<button type="button" class="tier-select-btn">Select</button></div>"#,
|
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>"#,
|
||||||
classes = classes,
|
classes = classes,
|
||||||
slug = slug_attr,
|
slug = slug_attr,
|
||||||
popular_pill = popular_pill,
|
popular_pill = popular_pill,
|
||||||
@@ -1306,8 +1323,7 @@ fn render_tier_picker(
|
|||||||
trial_banner = trial_banner,
|
trial_banner = trial_banner,
|
||||||
trial_meta = trial_meta,
|
trial_meta = trial_meta,
|
||||||
description_html = description_html,
|
description_html = description_html,
|
||||||
first_block = first_block,
|
features_html = features_html,
|
||||||
second_block = second_block,
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ const RELEASE_NOTES = [
|
|||||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||||
// append here.
|
// append here.
|
||||||
const ROUTINE_NOTES = [
|
const ROUTINE_NOTES = [
|
||||||
|
'0.2.0:27 — **Tier-card feature list merged into a single ul; "MOST POPULAR" + "Limited: …" no longer collide.** Two remaining buy-page nits from :26.',
|
||||||
|
'',
|
||||||
|
'**Single feature list.** The :26 attempt at zeroing the gap between adjacent `<ul.tier-bullets>` and `<ul.tier-entitlements>` worked on paper but the boundary was still visible to the eye — probably from list-item bottom-padding accumulating around the ul switch. Solved structurally: the daemon now builds ONE `<ul class="tier-features">` server-side, concatenating marketing bullets and entitlements in the operator-controlled order. No list boundary = no gap to fight with CSS. Both groups render with the same ✓ checkmark, indistinguishable to the buyer.',
|
||||||
|
'',
|
||||||
|
'**"MOST POPULAR" no longer collides with the launch-special meta line.** In :26 the popular pill was moved inside the card (top:8px) for tiers that also had a launch ribbon, so the ribbon\'s overflow-hidden wouldn\'t clip it. But that landed it on top of the "Limited: 100 of 100 remaining" line. Added `padding-top:36px` (35px when also `.selected`, to compensate for the thicker border) to the tier card when both classes are present, so the popular pill has its own gap above the meta line.',
|
||||||
|
'',
|
||||||
|
'**Test count: 87** (unchanged — CSS + HTML composition only).',
|
||||||
|
'',
|
||||||
|
'**Upgrade path.** v0.2.0:26 → v0.2.0:27 is a drop-in. No data, schema, or API change. SDK consumers that read `/v1/products/<slug>/policies` still get `entitlements` and `marketing_bullets` as separate arrays — the merging happens only at the buy-page render layer.',
|
||||||
|
'',
|
||||||
'0.2.0:26 — **Buy-page + entitlement-picker polish.** Cluster of small visual fixes informed by side-by-side review of the public buy page and the admin entitlements UI.',
|
'0.2.0:26 — **Buy-page + entitlement-picker polish.** Cluster of small visual fixes informed by side-by-side review of the public buy page and the admin entitlements UI.',
|
||||||
'',
|
'',
|
||||||
'**Tier-card feature list reads as one column.** When marketing bullets and entitlements stacked (either above or below), there was ~8px between the two lists but only 6px between items within a list — the seam was visible. Zeroed the margin-top on the second list so the two render as one continuous feature ladder (the per-li padding now contributes the full inter-item gap on both sides of the boundary).',
|
'**Tier-card feature list reads as one column.** When marketing bullets and entitlements stacked (either above or below), there was ~8px between the two lists but only 6px between items within a list — the seam was visible. Zeroed the margin-top on the second list so the two render as one continuous feature ladder (the per-li padding now contributes the full inter-item gap on both sides of the boundary).',
|
||||||
@@ -420,7 +430,7 @@ const ROUTINE_NOTES = [
|
|||||||
].join('\n\n')
|
].join('\n\n')
|
||||||
|
|
||||||
export const v0_2_0 = VersionInfo.of({
|
export const v0_2_0 = VersionInfo.of({
|
||||||
version: '0.2.0:26',
|
version: '0.2.0:27',
|
||||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||||
// SQLite-level migrations live separately under
|
// SQLite-level migrations live separately under
|
||||||
|
|||||||
Reference in New Issue
Block a user