diff --git a/odoo-community-developers/index.html b/odoo-community-developers/index.html
index d9adc56..d9d25b6 100644
--- a/odoo-community-developers/index.html
+++ b/odoo-community-developers/index.html
@@ -518,7 +518,15 @@
color: var(--brass-deep);
}
.dev-bio {
- margin: 0;
+ /* The card reads as three blocks: the head/clients/focus block at the top, the
+ description centred in the free space, and the charts + ask row + links block
+ sitting on the card's floor. In a flex column an auto block margin absorbs the
+ card's free space equally above and below the description, which centres it
+ between the two blocks AND pushes the charts, the ask row and the links row
+ down — so the ask button lands at the same height on every card in the row.
+ No wrapper element, no markup change. The description is never clipped to
+ achieve this: it renders whatever the form gave it, up to 230 characters. */
+ margin-block: auto;
font-size: 0.9rem;
line-height: 1.65;
color: var(--ink-soft);
@@ -562,6 +570,13 @@
align-items: center;
flex-wrap: wrap;
gap: 0.3rem;
+ /* The row is reserved, not filled: the script always keeps this container in the
+ DOM, even for a card with no link value at all, and this holds its line at the
+ height the 36 px hit areas already give it. An empty container is invisible but
+ occupies the same height as one full of logos, so the ask row above it sits at
+ the same height on every card — including a card with no logos. No empty anchor,
+ no empty icon, no placeholder text. */
+ min-height: 36px;
}
.dev-links a {
color: var(--midnight);
@@ -2515,8 +2530,14 @@
var focus = capFocus(rec.Focus);
if (focus) card.appendChild(liveEl("p", "dev-focus", focus));
- var bio = capDescription(rec.description);
- if (bio) card.appendChild(liveEl("p", "dev-bio", bio));
+ // The description container is ALWAYS in the DOM, like the links row further down:
+ // it is the element whose auto block margins centre it and push the bottom block
+ // onto the card's floor. A description is optional on the application form, and
+ // with no container the free space would have nothing to sit on — the charts, the
+ // ask row and the links row would float up off the floor and the ask button would
+ // miss the line every other card in the row is on. Reserved, not filled: an empty
+ // container reads as nothing, and a freelancer's own words are never clipped.
+ card.appendChild(liveEl("p", "dev-bio", capDescription(rec.description)));
var rows = liveAxisRows(rec);
if (rows.length) {
@@ -2557,11 +2578,14 @@
// while ODOO_EXPERTS_QUOTE_WEBHOOK is set). The address itself is never rendered —
// that is the whole point of the button.
if (canAsk(rec)) card.setAttribute("data-relay", "1");
- if (links.length) {
- var row = liveEl("p", "dev-links");
- links.forEach(function (a) { row.appendChild(a); });
- card.appendChild(row);
- }
+ // The links row is ALWAYS in the DOM — even with nothing in it. An empty container
+ // costs exactly one reserved line (see .dev-links min-height) and keeps the ask row
+ // above it at the same height on every card; an ABSENT container would let the ask
+ // row drop a line on a card with no link value. Reserved, not filled: no empty
+ // anchor, no empty icon, no placeholder text.
+ var row = liveEl("p", "dev-links");
+ links.forEach(function (a) { row.appendChild(a); });
+ card.appendChild(row);
if (!canAsk(rec)) {
card.appendChild(liveEl("p", "dev-contact-none",
"We have no way to pass your question to this freelancer."));