From bfcfe0f464c43413d115cc2844fe943b01a3b009 Mon Sep 17 00:00:00 2001 From: EddyOne81 Date: Sun, 20 Sep 2026 23:45:23 -0700 Subject: [PATCH 1/2] fix(billing): keep the org name and subdomain the shopper typed, and check the subdomain as they type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things on the Checkout tab's org bootstrap (the Organization name + Subdomain pair, shown only when a personal account picks Team or Business). 1. The typed values came back as the auto-suggestions. Those two inputs were painted from `ui._defaultOrgName()` / `_defaultOrgIdent()` and nothing ever wrote what was typed anywhere, so the first re-render put the suggestion back. The page re-renders on its own: `visibilitychange` re-syncs the subscription the moment the browser tab regains focus, which is exactly the report — switch tab, come back, the name you entered is gone. Switching billing cycle did it too. Both fields now `watch` their edits into `state.checkout` (input/change/paste/cut, so a mouse paste counts), and orgFieldValue() paints from state, treating "" as a deliberate clear rather than "untouched". 2. A taken subdomain was only reported after pressing Proceed to Checkout. The same payment.validate_org_ident call now runs 450 ms after the typing stops — and once for the auto-suggested value nobody typed, which is derived from the username and just as able to be taken — with the verdict under the field. Deduped and race-guarded on the ident, so a re-render asks nothing and a slow reply cannot label a value the shopper has since changed. A failed round trip stays silent: the check on Pay is still the one that blocks. 3. Proceed to Checkout went grey and dead after that alert. `state: 1` gives ui-core's toggle behavior, whose onAlsoClick flips data-state 1 -> 0 on the very click that submits, and the skin greys `[data-state="0"]` out with pointer-events:none. Invisible while the click ended in a redirect to Stripe; on every path that stays on the page it left a button that could not be pressed again. Dropped the prop — disabled-ness is `dataset.disabled`, which the same skin rule already styles. Verified against the shipped skeleton with a 9-case harness (typed value wins, cleared stays cleared, untouched falls back, section only for team/business, button carries no state, free plan still disabled, verdict slot empty then is-error / is-ok). Non-vacuous: 6 of the 9 fail on the pre-fix file. Co-Authored-By: Claude Opus 5 (1M context) --- locale/en.json | 1 + locale/es.json | 1 + locale/fr.json | 1 + locale/km.json | 1 + locale/ru.json | 1 + locale/zh.json | 1 + .../widget/settings/account/billing/index.js | 153 +++++++++++++++++- .../account/billing/skeleton/checkout.js | 86 +++++++++- .../settings/account/billing/skin/index.scss | 19 +++ 9 files changed, 258 insertions(+), 6 deletions(-) diff --git a/locale/en.json b/locale/en.json index b9007a1c7..6cacc61a2 100644 --- a/locale/en.json +++ b/locale/en.json @@ -523,6 +523,7 @@ "ORG_IDENT_REQUIRED": "Enter your organization name and subdomain to continue.", "ORG_IDENT_INVALID": "This subdomain is not valid — use lowercase letters, numbers and dashes.", "ORG_IDENT_TAKEN": "This subdomain is already taken. Please choose another one.", + "ORG_IDENT_AVAILABLE": "This subdomain is available.", "ORG_ALREADY_IN_DOMAIN": "Your account already belongs to an organization.", "ORG_PROVISIONED": "Your organization space is ready — reloading to your new address…", "ROLE_VIEW": "View", diff --git a/locale/es.json b/locale/es.json index dba3bdb3a..f1b338733 100644 --- a/locale/es.json +++ b/locale/es.json @@ -973,6 +973,7 @@ "ORG_IDENT_REQUIRED": "Enter your organization name and subdomain to continue.", "ORG_IDENT_INVALID": "This subdomain is not valid — use lowercase letters, numbers and dashes.", "ORG_IDENT_TAKEN": "This subdomain is already taken. Please choose another one.", + "ORG_IDENT_AVAILABLE": "This subdomain is available.", "ORG_ALREADY_IN_DOMAIN": "Your account already belongs to an organization.", "ORG_PROVISIONED": "Your organization space is ready — reloading to your new address…", "ROLE_VIEW": "View", diff --git a/locale/fr.json b/locale/fr.json index 5b5bea4e2..289a6acc4 100644 --- a/locale/fr.json +++ b/locale/fr.json @@ -1590,6 +1590,7 @@ "ORG_IDENT_REQUIRED": "Saisissez le nom et le sous-domaine de votre organisation pour continuer.", "ORG_IDENT_INVALID": "Ce sous-domaine n'est pas valide — utilisez uniquement des minuscules, des chiffres et des tirets.", "ORG_IDENT_TAKEN": "Ce sous-domaine est déjà pris. Veuillez en choisir un autre.", + "ORG_IDENT_AVAILABLE": "Ce sous-domaine est disponible.", "ORG_ALREADY_IN_DOMAIN": "Votre compte appartient déjà à une organisation.", "ORG_PROVISIONED": "L'espace de votre organisation est prêt — rechargement vers votre nouvelle adresse…", "ROLE_VIEW": "Lecture", diff --git a/locale/km.json b/locale/km.json index 98cb384bb..3099d46b2 100644 --- a/locale/km.json +++ b/locale/km.json @@ -1590,6 +1590,7 @@ "ORG_IDENT_REQUIRED": "Enter your organization name and subdomain to continue.", "ORG_IDENT_INVALID": "This subdomain is not valid — use lowercase letters, numbers and dashes.", "ORG_IDENT_TAKEN": "This subdomain is already taken. Please choose another one.", + "ORG_IDENT_AVAILABLE": "This subdomain is available.", "ORG_ALREADY_IN_DOMAIN": "Your account already belongs to an organization.", "ORG_PROVISIONED": "Your organization space is ready — reloading to your new address…", "ROLE_VIEW": "View", diff --git a/locale/ru.json b/locale/ru.json index 05664d5e6..aa317733e 100644 --- a/locale/ru.json +++ b/locale/ru.json @@ -1590,6 +1590,7 @@ "ORG_IDENT_REQUIRED": "Enter your organization name and subdomain to continue.", "ORG_IDENT_INVALID": "This subdomain is not valid — use lowercase letters, numbers and dashes.", "ORG_IDENT_TAKEN": "This subdomain is already taken. Please choose another one.", + "ORG_IDENT_AVAILABLE": "This subdomain is available.", "ORG_ALREADY_IN_DOMAIN": "Your account already belongs to an organization.", "ORG_PROVISIONED": "Your organization space is ready — reloading to your new address…", "ROLE_VIEW": "View", diff --git a/locale/zh.json b/locale/zh.json index 1e98b274e..ef81153a9 100644 --- a/locale/zh.json +++ b/locale/zh.json @@ -1590,6 +1590,7 @@ "ORG_IDENT_REQUIRED": "Enter your organization name and subdomain to continue.", "ORG_IDENT_INVALID": "This subdomain is not valid — use lowercase letters, numbers and dashes.", "ORG_IDENT_TAKEN": "This subdomain is already taken. Please choose another one.", + "ORG_IDENT_AVAILABLE": "This subdomain is available.", "ORG_ALREADY_IN_DOMAIN": "Your account already belongs to an organization.", "ORG_PROVISIONED": "Your organization space is ready — reloading to your new address…", "ROLE_VIEW": "View", diff --git a/src/drumee/builtins/widget/settings/account/billing/index.js b/src/drumee/builtins/widget/settings/account/billing/index.js index 705465ef4..c83f6b5a9 100644 --- a/src/drumee/builtins/widget/settings/account/billing/index.js +++ b/src/drumee/builtins/widget/settings/account/billing/index.js @@ -23,6 +23,13 @@ const TAB_CHECKOUT = 2; // account, this is the single place that changes. const PROMO_YEARLY_SEEN_KEY = "drumee.promo.yearly.shown-on"; +// How long the subdomain field stays quiet before its availability is checked. +// Long enough that an ordinary typist produces one request per word rather +// than one per letter; short enough that the verdict is there before the hand +// reaches Proceed to Checkout. payment.validate_org_ident is a DB-only read +// (one proc + one count query), so this costs nothing Stripe-shaped. +const ORG_IDENT_DEBOUNCE_MS = 450; + const formatCurrency = (amount) => { return `$${amount.toFixed(2)}`; }; @@ -97,6 +104,7 @@ class settings_billing extends LetcBox { onBeforeDestroy() { this.unbindEvent(_a.live); clearTimeout(this._motionTimer); + clearTimeout(this._orgIdentTimer); this._stopPromoCountdown(); if (this._onVisibility) { document.removeEventListener("visibilitychange", this._onVisibility); @@ -1064,6 +1072,20 @@ class settings_billing extends LetcBox { break; case `${this.fig.family}__checkout-org-ident-input`: this.__orgIdentInput = child; + // Check whatever the field is SHOWING, including the auto-suggested + // subdomain nobody typed — that one is derived from the username and + // is just as able to be taken. Deduped on the value in + // _checkOrgIdent, so the re-renders this page does on its own never + // re-ask the same question. + this._scheduleOrgIdentCheck( + this.state?.checkout?.orgIdent != null + ? this.state.checkout.orgIdent + : this._defaultOrgIdent(), + ); + break; + + case `${this.fig.family}__checkout-org-ident-msg`: + this.__orgIdentMsg = child; break; case `${this.fig.family}__checkout-promo-code-input`: this.__promoCodeInput = child; @@ -1652,6 +1674,113 @@ class settings_billing extends LetcBox { .replace(/-+$/g, ""); } + /** + * Keep an org bootstrap field's typed value in state. + * + * The `watch` option on those entries fires on input/change/paste/cut, so + * this catches a mouse paste as well as typing — the keyup path the seats + * field uses would miss it. Deliberately does NOT re-render: the value is + * only read back when something ELSE rebuilds the tab, and re-rendering per + * keystroke would take the caret out of the field. + * + * @param {string} key - "orgName" or "orgIdent" + * @param {Object} args - the watch payload ({ value }) + */ + _onOrgFieldTyped(key, args = {}) { + const checkout = this.state.checkout || (this.state.checkout = {}); + const value = String(args.value != null ? args.value : ""); + checkout[key] = value; + if (key === "orgIdent") this._scheduleOrgIdentCheck(value); + } + + /** + * Ask, a short pause after the typing stops, whether this subdomain is free. + * + * Emptying the field clears the verdict rather than asking about "" — the + * server would answer IDENT_INVALID, which is not a useful thing to say + * about a field the shopper is in the middle of retyping. + * + * @param {string} raw - the field's current text + */ + _scheduleOrgIdentCheck(raw) { + clearTimeout(this._orgIdentTimer); + const ident = String(raw || "").trim().toLowerCase(); + if (!ident) { + this._orgIdentChecked = ""; + this._setOrgIdentMsg("", false); + return; + } + this._orgIdentTimer = setTimeout( + () => this._checkOrgIdent(ident), + ORG_IDENT_DEBOUNCE_MS, + ); + } + + /** + * Run payment.validate_org_ident and show the verdict under the field. + * + * The same call Proceed to Checkout makes, so the two can never disagree; + * this only moves the answer to where it is useful. `_orgIdentChecked` both + * de-duplicates (a re-render, or retyping the same value, asks nothing) and + * settles races: it holds the ident whose answer is still wanted, so a slow + * reply for an abandoned value is dropped instead of labelling the field + * the shopper has since changed. + * + * @param {string} ident - normalised subdomain label + */ + async _checkOrgIdent(ident) { + if (this.isDestroyed() || ident === this._orgIdentChecked) return; + this._orgIdentChecked = ident; + const v = await this.postService(SERVICE.payment.validate_org_ident, { + hub_id: Visitor.id, + ident, + }).catch(() => null); + if (this.isDestroyed() || this._orgIdentChecked !== ident) return; + if (!v) { + // A failed round trip says nothing about the subdomain. Stay silent and + // let the check on Pay be the one that blocks — claiming "taken" here + // over a dropped connection would send the shopper renaming their org + // for no reason. Forget it, so the next keystroke asks again. + this._orgIdentChecked = ""; + this._setOrgIdentMsg("", false); + return; + } + const ok = v.status === "OK"; + this._setOrgIdentMsg( + ok + ? (LOCALE.ORG_IDENT_AVAILABLE || "") + : this._orgIdentError(v.status), + ok, + ); + } + + /** + * Record the subdomain verdict and repaint it. + * @param {string} msg - message to show, "" for none + * @param {boolean} ok - true when the subdomain is available + */ + _setOrgIdentMsg(msg, ok) { + const checkout = this.state.checkout || (this.state.checkout = {}); + const text = msg || ""; + if (checkout.orgIdentMsg === text && checkout.orgIdentOk === !!ok) return; + checkout.orgIdentMsg = text; + checkout.orgIdentOk = !!ok; + this._paintOrgIdentMsg(); + } + + /** + * Feed the verdict into its slot — the ONE surface that changes, so the + * inputs beside it keep their caret and their text. + */ + _paintOrgIdentMsg() { + const part = this.__orgIdentMsg; + if (!part || !part.el || !part.el.isConnected) return; + const { orgIdentMsgNote } = require("./skeleton/checkout"); + if (typeof part.softClear === "function") part.softClear(); + const note = orgIdentMsgNote(this); + if (note) part.feed(note); + } + // Map an org-ident validation status to its user-facing message. /** * Open the user's mail client addressed to sales. @@ -1971,9 +2100,21 @@ class settings_billing extends LetcBox { ident, }).catch(() => null); if (!v || v.status !== "OK") { - if (Wm && Wm.alert) Wm.alert(this._orgIdentError(v && v.status)); + const reason = this._orgIdentError(v && v.status); + // Leave a real verdict ON the field, not only in an alert the shopper + // has to dismiss before they can see which field it was about. A + // failed round trip (`v` null) gets the alert only — pinning "something + // went wrong" under the subdomain would blame the field for the + // network. + if (v) { + this._orgIdentChecked = ident; + this._setOrgIdentMsg(reason, false); + } + if (Wm && Wm.alert) Wm.alert(reason); return; } + this._orgIdentChecked = ident; + this._setOrgIdentMsg(LOCALE.ORG_IDENT_AVAILABLE || "", true); payload.ident = v.ident; payload.org_name = org_name; } @@ -2493,6 +2634,16 @@ class settings_billing extends LetcBox { case "select-bundle": return this._handleSelectBundle(cmd, args); + // The org bootstrap fields mirror themselves into state as they are + // edited — see orgFieldValue() in skeleton/checkout for why they have to. + case "org-name-typed": + this._onOrgFieldTyped("orgName", args); + return false; + + case "org-ident-typed": + this._onOrgFieldTyped("orgIdent", args); + return false; + case "input-seats": if (/^(Backspace|)$/.test(cmd.status)) { return diff --git a/src/drumee/builtins/widget/settings/account/billing/skeleton/checkout.js b/src/drumee/builtins/widget/settings/account/billing/skeleton/checkout.js index 977e7e124..e7aa53643 100644 --- a/src/drumee/builtins/widget/settings/account/billing/skeleton/checkout.js +++ b/src/drumee/builtins/widget/settings/account/billing/skeleton/checkout.js @@ -35,6 +35,52 @@ function pillBar(ui, segments) { }); } +/** + * The value an org bootstrap field paints with. + * + * `saved` is what the shopper has typed — mirrored into state.checkout on + * every keystroke by the `watch` services below — and `fallback` is the + * auto-suggestion. It has to come from state because EVERY re-render of this + * tab rebuilds these inputs from `value`, and re-renders happen with nobody + * touching the page: the visibilitychange re-sync fires the moment the browser + * tab regains focus, so switching to another tab and back used to hand the + * typed name and subdomain straight back to the auto-suggested ones + * (reported 2026-09-20). + * + * An empty string is a deliberate clear, not "untouched", so it must survive + * that repaint too — hence the null check rather than `||`. Submitting an + * empty field still falls back to the same auto value in _proceedToCheckout. + * + * @param {string|undefined} saved - value mirrored from the input, if any + * @param {string} fallback - the auto-derived suggestion + * @returns {string} value to paint the input with + */ +function orgFieldValue(saved, fallback) { + return String(saved != null ? saved : (fallback || "")); +} + +/** + * Inline verdict for the subdomain field — the answer to "is this one already + * taken?" while the shopper is still typing, instead of only after they press + * Proceed to Checkout. + * + * Built from state, so a full re-render repaints the same verdict; the widget + * also feeds it into the -org-ident-msg slot on its own when a check lands. + * + * @param {Object} ui - UI instance + * @returns {Object|null} a Note, or null when there is nothing to say + */ +function orgIdentMsgNote(ui) { + const pfx = `${ui.fig.family}__checkout`; + const checkout = (ui.state && ui.state.checkout) || {}; + const msg = checkout.orgIdentMsg || ""; + if (!msg) return null; + return Skeletons.Note({ + className: `${pfx}-org-ident-msg ${checkout.orgIdentOk ? "is-ok" : "is-error"}`, + content: msg, + }); +} + /** * Create checkout layout with left panel (form) and right panel (summary) * Left panel: plan selection, seats, storage, billing cycle, storage bundles @@ -81,9 +127,13 @@ function checkout(ui) { placeholder: LOCALE.ORG_NAME_LABEL, // Auto organization name (" Team") — editable; the submit // path falls back to the same default if the field is cleared. - value: String(ui.state?.checkout?.orgName || ui._defaultOrgName() || ""), + value: orgFieldValue(ui.state?.checkout?.orgName, ui._defaultOrgName()), sys_pn: `${pfx}-org-name-input`, interactive: 1, + // See orgFieldValue(): `watch` is what mirrors each keystroke (and + // each paste) into state.checkout, so the line above has something + // to repaint after a re-render. + watch: "org-name-typed", }), Skeletons.Box.X({ className: `${pfx}-org-ident-row`, @@ -94,10 +144,12 @@ function checkout(ui) { type: "text", placeholder: LOCALE.ORG_SUBDOMAIN_LABEL, // Auto subdomain suggestion (slugged username) — editable; - // availability is still checked by validate_org_ident. - value: String(ui.state?.checkout?.orgIdent || ui._defaultOrgIdent() || ""), + // availability is checked by validate_org_ident, now while it + // is being typed (see -org-ident-msg below) as well as on Pay. + value: orgFieldValue(ui.state?.checkout?.orgIdent, ui._defaultOrgIdent()), sys_pn: `${pfx}-org-ident-input`, interactive: 1, + watch: "org-ident-typed", }), Skeletons.Note({ className: `${pfx}-org-ident-suffix`, @@ -109,6 +161,17 @@ function checkout(ui) { className: `${pfx}-org-ident-hint`, content: LOCALE.ORG_URL_HINT, }), + // Slot for the live availability verdict. A Box, not the Note + // itself: a Note rebuilds its own inner .note-content, so the + // framework's update path is to feed() a fresh Note into a + // container that owns a sys_pn — the same shape the promo countdown + // uses. Empty (and display:none) until there is something to say. + Skeletons.Box.X({ + className: `${pfx}-org-ident-msg-slot`, + sys_pn: `${pfx}-org-ident-msg`, + partHandler: [ui], + kids: [orgIdentMsgNote(ui)].filter(Boolean), + }), ], }) : null; @@ -430,7 +493,20 @@ function rightPanelContent(ui) { priority: "primary", uiHandler: [ui], bubble: false, - state: isFreePlan ? 0 : 1, + // NO `state` prop, deliberately. ui-core attaches the TOGGLE behavior + // to any widget carrying one (addons/backbone/view/behavior.js), and + // its onAlsoClick flips data-state 1 → 0 on the very click that + // submits — while the skin greys `[data-state="0"]` out with + // pointer-events:none. Nobody noticed while the click ended in a + // redirect to Stripe, but every path that STAYS on the page (the + // subdomain is taken, ALREADY_SUBSCRIBED, a network error) left the + // shopper looking at a dead grey button: fix the subdomain, and there + // was no way to press Pay again short of reloading (reported + // 2026-09-20). The entry fields escape this only because entry() + // gives them a `radio`, which wins over toggle. + // + // Disabled-ness is carried by `dataset.disabled` alone — the same skin + // rule styles it, and nothing flips it behind our back. dataset: isFreePlan ? { disabled: 1 } : undefined, }), ].filter(Boolean); @@ -461,4 +537,4 @@ function rightPanel(ui) { } export default checkout; -export { rightPanel, rightPanelContent, promoCodeSection }; +export { rightPanel, rightPanelContent, promoCodeSection, orgIdentMsgNote }; diff --git a/src/drumee/builtins/widget/settings/account/billing/skin/index.scss b/src/drumee/builtins/widget/settings/account/billing/skin/index.scss index 39c244374..65f140ebc 100644 --- a/src/drumee/builtins/widget/settings/account/billing/skin/index.scss +++ b/src/drumee/builtins/widget/settings/account/billing/skin/index.scss @@ -1125,6 +1125,25 @@ ); } + // The availability verdict's slot. It is empty until a check comes back, + // and an empty box would still draw the org section's 12px gap — so it + // takes itself out of the flow until it has a Note in it. + &-org-ident-msg-slot:empty { + display: none; + } + + &-org-ident-msg { + @include drumee.typo($size: 12px, $line: 16px, $weight: 500); + + &.is-error { + color: var(--color-danger, #c0392b); + } + + &.is-ok { + color: var(--color-success, #1f8a4c); + } + } + &-left { // min-width:0 lets the column actually shrink — a flex item defaults to // min-width:auto, which is what pinned it at min-content and pushed the From ebd252e36539e781d8d43b7f84cd48eeaaa37c52 Mon Sep 17 00:00:00 2001 From: EddyOne81 Date: Mon, 21 Sep 2026 00:06:17 -0700 Subject: [PATCH 2/2] fix(billing): collapse the empty subdomain-verdict slot with data-empty, not :empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser-verified on drumee.in and the `:empty` rule never fired: ui-core keeps a `blank` widget inside a Box built with no kids, so the slot element is never childless. The slot therefore stayed in the flow and drew the org section's 12px gap around nothing — 36px between the hint and "Billing Cycle" instead of 24px. The skeleton now stamps `data-empty` and _paintOrgIdentMsg keeps it in step on the incremental path, which is the only one that can change the slot's contents without a re-render. Co-Authored-By: Claude Opus 5 (1M context) --- .../builtins/widget/settings/account/billing/index.js | 4 ++++ .../settings/account/billing/skeleton/checkout.js | 10 +++++++++- .../widget/settings/account/billing/skin/index.scss | 9 +++++---- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/drumee/builtins/widget/settings/account/billing/index.js b/src/drumee/builtins/widget/settings/account/billing/index.js index c83f6b5a9..b3cf0a147 100644 --- a/src/drumee/builtins/widget/settings/account/billing/index.js +++ b/src/drumee/builtins/widget/settings/account/billing/index.js @@ -1778,6 +1778,10 @@ class settings_billing extends LetcBox { const { orgIdentMsgNote } = require("./skeleton/checkout"); if (typeof part.softClear === "function") part.softClear(); const note = orgIdentMsgNote(this); + // Keep the collapse flag in step with what is actually in the slot — see + // the skeleton: a Box with no kids still holds ui-core's `blank` widget, + // so only this attribute can tell the skin the slot has nothing to show. + part.el.dataset.empty = note ? 0 : 1; if (note) part.feed(note); } diff --git a/src/drumee/builtins/widget/settings/account/billing/skeleton/checkout.js b/src/drumee/builtins/widget/settings/account/billing/skeleton/checkout.js index e7aa53643..a184b9f02 100644 --- a/src/drumee/builtins/widget/settings/account/billing/skeleton/checkout.js +++ b/src/drumee/builtins/widget/settings/account/billing/skeleton/checkout.js @@ -165,11 +165,19 @@ function checkout(ui) { // itself: a Note rebuilds its own inner .note-content, so the // framework's update path is to feed() a fresh Note into a // container that owns a sys_pn — the same shape the promo countdown - // uses. Empty (and display:none) until there is something to say. + // uses. + // + // `data-empty` rather than a `:empty` CSS rule: ui-core puts a + // `blank` widget inside a Box with no kids, so the element is never + // actually childless and `:empty` never matches. Measured on + // drumee.in — the rule did nothing and the empty slot still drew the + // org section's 12px gap. _paintOrgIdentMsg keeps this flag in step + // on the incremental path. Skeletons.Box.X({ className: `${pfx}-org-ident-msg-slot`, sys_pn: `${pfx}-org-ident-msg`, partHandler: [ui], + dataset: { empty: orgIdentMsgNote(ui) ? 0 : 1 }, kids: [orgIdentMsgNote(ui)].filter(Boolean), }), ], diff --git a/src/drumee/builtins/widget/settings/account/billing/skin/index.scss b/src/drumee/builtins/widget/settings/account/billing/skin/index.scss index 65f140ebc..f43150e40 100644 --- a/src/drumee/builtins/widget/settings/account/billing/skin/index.scss +++ b/src/drumee/builtins/widget/settings/account/billing/skin/index.scss @@ -1125,10 +1125,11 @@ ); } - // The availability verdict's slot. It is empty until a check comes back, - // and an empty box would still draw the org section's 12px gap — so it - // takes itself out of the flow until it has a Note in it. - &-org-ident-msg-slot:empty { + // The availability verdict's slot. Out of the flow until a check comes + // back, or it would draw the org section's 12px gap around nothing. + // Driven by data-empty, NOT `:empty` — ui-core keeps a `blank` widget + // inside a Box with no kids, so the element is never childless. + &-org-ident-msg-slot[data-empty="1"] { display: none; }