diff --git a/licensing-service/src/api/buy_page.rs b/licensing-service/src/api/buy_page.rs
index 3a5d10e..407cbeb 100644
--- a/licensing-service/src/api/buy_page.rs
+++ b/licensing-service/src/api/buy_page.rs
@@ -341,7 +341,15 @@ h1 {{
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. */
+ 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;
}}
@@ -391,24 +399,21 @@ h1 {{
.tier-price-original-unit {{
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
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;
font-size:13px; color:var(--ink-700);
}}
-.tier-entitlements li, .tier-bullets li {{
+.tier-features li {{
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;
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 {{
margin-top:auto;
padding:8px 12px;
@@ -1148,23 +1153,21 @@ fn render_tier_picker(
.and_then(|v| v.as_str())
.map(|s| s == "below")
.unwrap_or(false);
- let marketing_html = p
+ // Marketing-bullet items (just the
s — we'll merge them
+ // with entitlement items into a single
below so the two
+ // groups read as ONE continuous feature ladder with zero
+ // boundary artifact between them).
+ let marketing_lis: Vec = p
.metadata
.get("marketing_bullets")
.and_then(|v| v.as_array())
.map(|arr| {
- let lis: Vec = arr
- .iter()
+ arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| format!("
", lis.join(""))
- }
+ .collect()
})
.unwrap_or_default();
// raw slug if the catalog is empty or the slug isn't in
@@ -1186,11 +1189,13 @@ fn render_tier_picker(
.iter()
.filter(|s| !hidden_on_buy.contains(s.as_str()))
.collect();
- let entitlements_html = if visible_entitlements.is_empty() {
- String::new()
- } else {
+ // Entitlement
s. Same format as marketing items so the
+ // merged feature list looks uniform — visual distinction
+ // between an "entitlement" and a "marketing bullet" is
+ // intentionally invisible to the buyer.
+ let entitlement_lis: Vec = {
let catalog = product.entitlements_catalog.as_deref().unwrap_or(&[]);
- let lis: Vec = visible_entitlements
+ visible_entitlements
.iter()
.map(|slug| {
let entry = catalog.iter().find(|e| &e.slug == *slug);
@@ -1208,8 +1213,26 @@ fn render_tier_picker(
html_escape(display),
)
})
- .collect();
- format!("
{}
", lis.join(""))
+ .collect()
+ };
+ // Merge into a SINGLE
so there's
+ // no list-boundary gap to fight with CSS. Order respects the
+ // operator's marketing_bullets_position metadata.
+ let merged_lis: Vec = 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!("
{}
", merged_lis.join(""))
};
let dur_html = if p.duration_seconds > 0 {
let days = p.duration_seconds / 86_400;
@@ -1284,14 +1307,8 @@ fn render_tier_picker(
} else {
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!(
- r#"
"#,
classes = classes,
slug = slug_attr,
popular_pill = popular_pill,
@@ -1306,8 +1323,7 @@ fn render_tier_picker(
trial_banner = trial_banner,
trial_meta = trial_meta,
description_html = description_html,
- first_block = first_block,
- second_block = second_block,
+ features_html = features_html,
)
})
.collect();
diff --git a/startos/versions/v0.2.0.ts b/startos/versions/v0.2.0.ts
index 6cce3a1..769968c 100644
--- a/startos/versions/v0.2.0.ts
+++ b/startos/versions/v0.2.0.ts
@@ -58,6 +58,16 @@ const RELEASE_NOTES = [
// in RELEASE_NOTES above (the milestone). Subsequent revisions
// append here.
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 `` and `` 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 `
` 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//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.',
'',
'**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')
export const v0_2_0 = VersionInfo.of({
- version: '0.2.0:26',
+ version: '0.2.0:27',
releaseNotes: { en_US: ROUTINE_NOTES },
// No on-disk transformation needed — v0.2.0:0 is a label change.
// SQLite-level migrations live separately under