Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions apps/web/src/lib/billing-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ export function renderPlanCardsGridHtml(
planSource: billing.planSource,
});

const cardClass =
"plan-card flex flex-col gap-2 rounded-[10px] border border-border p-4 data-[current=true]:border-foreground";
const ctaClass =
"plan-card__cta mt-auto self-start rounded-md border border-border bg-card px-3 py-1.5 text-xs tracking-wide text-primary hover:border-primary focus-visible:border-primary focus-visible:outline-none disabled:cursor-default disabled:opacity-60";

return cardPlans
.map((plan) => {
const isCurrent = plan.id === currentPlanId;
Expand All @@ -210,16 +215,16 @@ export function renderPlanCardsGridHtml(
if (!isCurrent && plan.id === "pro") {
footer =
cta.kind === "unavailable"
? `<button type="button" class="plan-card__cta" disabled>Coming soon</button>`
: `<button type="button" class="plan-card__cta" data-cta="upgrade" data-plan="pro">Upgrade to Pro</button>`;
? `<button type="button" class="${ctaClass}" disabled>Coming soon</button>`
: `<button type="button" class="${ctaClass}" data-cta="upgrade" data-plan="pro">Upgrade to Pro</button>`;
}
return `
<div class="plan-card${isCurrent ? " is-current" : ""}" data-plan="${plan.id}">
${isCurrent ? `<span class="plan-card__badge">Current plan</span>` : ""}
<p class="plan-card__name">${escapeHtml(plan.name)}</p>
${priceLine ? `<p class="plan-card__price">${escapeHtml(priceLine)}</p>` : ""}
<p class="muted plan-card__blurb">${escapeHtml(plan.blurb)}</p>
<ul class="plan-card__limits muted">${planCardLimitRows(plan)}</ul>
<div class="${cardClass}" data-plan="${plan.id}" data-current="${isCurrent}">
${isCurrent ? `<span class="plan-card__badge self-start rounded-full bg-foreground px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-background">Current plan</span>` : ""}
<p class="plan-card__name m-0 text-sm font-semibold">${escapeHtml(plan.name)}</p>
${priceLine ? `<p class="plan-card__price m-0 text-xs text-muted-foreground">${escapeHtml(priceLine)}</p>` : ""}
<p class="muted plan-card__blurb m-0 text-xs">${escapeHtml(plan.blurb)}</p>
<ul class="plan-card__limits muted mt-1 grid list-none gap-1 p-0 text-[11px]">${planCardLimitRows(plan)}</ul>
${footer}
</div>`;
})
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lib/workspace-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe("renderMembersHtml", () => {

it("leads with the email when there is no display name, without a sub-line", () => {
const html = renderMembersHtml([{ email: "c@d.com", name: "", role: "member" }]);
expect(html).toContain('member-row__name">c@d.com<');
expect(html).toMatch(/member-row__name[^"]*">c@d\.com</);
expect(html).not.toContain("member-row__email");
});

Expand Down Expand Up @@ -462,7 +462,7 @@ describe("skeleton placeholders", () => {

it("mirrors the real member row's two-column structure", () => {
const html = renderMembersPlaceholderHtml(2);
expect(html.match(/class="member-row"/g)).toHaveLength(2);
expect(html.match(/class="member-row /g)).toHaveLength(2);
// Both halves present, or the flex row collapses and the swap rearranges.
expect(html).toContain("member-row__who");
expect(html).toContain("member-row__name");
Expand Down
35 changes: 23 additions & 12 deletions apps/web/src/lib/workspace-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,21 +158,32 @@ export function canManageMemberRow(member: MemberRow, opts: MemberRowOptions): b
* People-tab member list. Name leads when set (email sub-line); manageable
* rows get role `<select>` + remove. `[]` → `""`.
*/
/** People-tab row chrome — shared by member and invite rows so both surfaces match exactly. */
const MEMBER_ROW_CLASS =
"member-row flex items-baseline justify-between gap-3.5 border-t border-border px-0.5 py-2 text-sm first:border-t-0";
const MEMBER_ROW_WHO_CLASS = "flex min-w-0 items-baseline gap-2.5";
const MEMBER_ROW_NAME_CLASS = "truncate font-semibold";
const MEMBER_ROW_ROLE_CLASS =
"shrink-0 rounded-md border border-transparent px-2.5 py-1 font-mono text-xs uppercase tracking-wider text-muted-foreground";
const MEMBER_ROW_ACTIONS_CLASS = "flex items-center gap-2";

export function renderMembersHtml(members: MemberRow[], opts: MemberRowOptions = {}): string {
return members
.map((m) => {
const lead = m.name || m.email;
const sub = m.name ? `<span class="member-row__email">${escapeHtml(m.email)}</span>` : "";
const sub = m.name
? `<span class="member-row__email truncate text-xs text-muted-foreground">${escapeHtml(m.email)}</span>`
: "";
const controls = canManageMemberRow(m, opts)
? `<span class="member-row__actions">` +
`<select class="member-row__role-select ul-select ul-select--sm" data-member-id="${escapeHtml(m.id!)}" aria-label="Role for ${escapeHtml(m.email)}">` +
? `<span class="${MEMBER_ROW_ACTIONS_CLASS}">` +
`<select class="member-row__role-select ul-select ul-select--sm w-auto" data-member-id="${escapeHtml(m.id!)}" aria-label="Role for ${escapeHtml(m.email)}">` +
`<option value="member"${m.role === "member" ? " selected" : ""}>member</option>` +
`<option value="admin"${m.role === "admin" ? " selected" : ""}>admin</option>` +
`</select>` +
`<button type="button" class="text-btn member-row__remove" data-member-id="${escapeHtml(m.id!)}" data-member-email="${escapeHtml(m.email)}">Remove</button>` +
`</span>`
: `<span class="member-row__role">${escapeHtml(m.role)}</span>`;
return `<div class="member-row"><span class="member-row__who"><span class="member-row__name">${escapeHtml(lead)}</span>${sub}</span>${controls}</div>`;
: `<span class="member-row__role ${MEMBER_ROW_ROLE_CLASS}">${escapeHtml(m.role)}</span>`;
return `<div class="${MEMBER_ROW_CLASS}"><span class="member-row__who ${MEMBER_ROW_WHO_CLASS}"><span class="member-row__name ${MEMBER_ROW_NAME_CLASS}">${escapeHtml(lead)}</span>${sub}</span>${controls}</div>`;
})
.join("");
}
Expand All @@ -191,9 +202,9 @@ export function renderMembersPlaceholderHtml(rows = 2): string {
return Array.from(
{ length: rows },
(_, i) =>
`<div class="member-row">` +
`<span class="member-row__who"><span class="member-row__name">${skeletonBarHtml(widths[i % widths.length])}</span></span>` +
`<span class="member-row__role">${skeletonBarHtml("42px")}</span>` +
`<div class="${MEMBER_ROW_CLASS}">` +
`<span class="member-row__who ${MEMBER_ROW_WHO_CLASS}"><span class="member-row__name ${MEMBER_ROW_NAME_CLASS}">${skeletonBarHtml(widths[i % widths.length])}</span></span>` +
`<span class="member-row__role ${MEMBER_ROW_ROLE_CLASS}">${skeletonBarHtml("42px")}</span>` +
`</div>`,
).join("");
}
Expand All @@ -209,10 +220,10 @@ export function renderInvitesHtml(
.map((inv) => {
const status = inv.status || "pending";
return (
`<div class="member-row member-row--pending">` +
`<span class="member-row__who"><span class="member-row__name">${escapeHtml(inv.email)}</span></span>` +
`<span class="member-row__actions">` +
`<span class="member-row__role member-row__role--pending">${escapeHtml(status)}</span>` +
`<div class="member-row member-row--pending ${MEMBER_ROW_CLASS}">` +
`<span class="member-row__who ${MEMBER_ROW_WHO_CLASS}"><span class="member-row__name truncate font-medium text-muted-foreground">${escapeHtml(inv.email)}</span></span>` +
`<span class="${MEMBER_ROW_ACTIONS_CLASS}">` +
`<span class="member-row__role member-row__role--pending shrink-0 rounded-md border border-transparent px-2.5 py-1 font-mono text-xs uppercase tracking-wider text-primary">${escapeHtml(status)}</span>` +
`<button type="button" class="text-btn invite-row__revoke" data-invite-id="${escapeHtml(inv.id)}" data-invite-email="${escapeHtml(inv.email)}">Revoke</button>` +
`</span></div>`
);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/pages/account/workspaces/[name]/billing.astro
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ const cta: BillingCtaState = billing
<h3>Plans</h3>
<div
id="ws-plan-cards"
class="plan-cards"
class="plan-cards my-1 mb-3.5 grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-3.5"
data-organization-id={billing ? billing.organization.id : undefined}
set:html={billing ? renderPlanCardsGridHtml(billing, proPriceCopy) : ""}
/>
Expand All @@ -113,6 +113,7 @@ const cta: BillingCtaState = billing
<button
type="button"
id="ws-manage-billing-btn"
class="rounded-md border border-border bg-card px-3 py-1.5 text-xs tracking-wide text-primary hover:border-primary focus-visible:border-primary focus-visible:outline-none disabled:cursor-default disabled:opacity-60"
hidden={cta.kind === "manage" ? undefined : true}>Manage billing</button
>
<p class="muted" id="ws-comped-note" hidden={cta.kind === "comped" ? undefined : true}>
Expand Down
167 changes: 0 additions & 167 deletions apps/web/src/styles/account-content.css
Original file line number Diff line number Diff line change
@@ -1,40 +1,5 @@
/* Content styles for /account/* cards (profile kv, workspace list, developer list). */

/* Billing tab — plan comparison cards. */
.plan-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
margin: 4px 0 14px;
}
.plan-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 10px;
}
.plan-card.is-current {
border-color: var(--fg);
}
.plan-card__badge {
align-self: flex-start;
padding: 2px 8px;
border-radius: 999px;
background: var(--fg);
color: var(--bg);
font-size: var(--text-micro);
font-weight: var(--weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.plan-card__name {
margin: 0;
font-size: var(--text-body);
font-weight: var(--weight-semibold);
}

/* Compact "Pro" marker shown outside the billing tab (issue #365 follow-up)
— same uppercase/pill idiom as .plan-card__badge, but a muted accent
outline rather than an inverse fg/bg fill so it reads as a small aside
Expand All @@ -51,53 +16,6 @@
letter-spacing: 0.04em;
line-height: 1.4;
}
.plan-card__price {
margin: 0;
font-size: var(--text-meta);
color: var(--muted);
}
.plan-card__blurb {
margin: 0;
font-size: var(--text-meta);
}
.plan-card__limits {
margin: 4px 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 4px;
font-size: var(--text-micro);
}
.plan-card__cta {
margin-top: auto;
align-self: flex-start;
}
/* Billing action buttons share the account area's standard button treatment
(same recipe as the invite-form submit) instead of native browser chrome. */
.plan-card__cta,
#ws-manage-billing-btn {
font: var(--text-micro) var(--sans);
letter-spacing: 0.03em;
color: var(--accent);
border: 1px solid var(--line);
border-radius: 6px;
padding: 7px 12px;
background: var(--panel);
cursor: pointer;
}
.plan-card__cta:hover,
.plan-card__cta:focus-visible,
#ws-manage-billing-btn:hover,
#ws-manage-billing-btn:focus-visible {
border-color: var(--accent);
outline: none;
}
.plan-card__cta:disabled,
#ws-manage-billing-btn:disabled {
opacity: 0.6;
cursor: default;
}

/* Settings pages: one card, hairline sections — no stacked cards / nested boxes. */
section.card.settings-page {
padding: 20px 22px 8px;
Expand Down Expand Up @@ -1742,63 +1660,6 @@ button.wft-card__name:focus-visible {
}
}

/* People tab — member rows (innerHTML-injected by renderMembersHtml). */
.member-list {
display: grid;
}
.member-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 14px;
padding: 9px 2px;
border-top: 1px solid var(--line);
font-size: var(--text-meta);
}
.member-row:first-child {
border-top: 0;
}
.member-row__who {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.member-row__name {
font-weight: var(--weight-semibold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-row__email {
color: var(--muted);
font-size: var(--text-micro);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A row's height must not depend on which of three shapes its role cell
happens to render: a plain label (read-only viewer), a <select>
(manageable viewer, styled via .ul-select--sm below), or, via
.member-row__actions below, a Remove/Revoke .text-btn (also a
manageable/pending viewer). .member-row__role mirrors .text-btn's own box
(font/padding/border, account-content.css:513) — the same box
.ul-select--sm mirrors — rather than a measured number, so all three
converge on whatever that rule computes to and stay correct if it ever
changes. letter-spacing/text-transform/color stay label-specific — neither
affects box height. Can't just apply .ul-select--sm here: this is a plain
<span>, not a <select>, so the caret/background-image would be wrong. */
.member-row__role {
flex: none;
font: var(--text-micro) var(--sans);
padding: 5px 9px;
border: 1px solid transparent;
border-radius: 6px;
color: var(--muted);
letter-spacing: 0.08em;
text-transform: uppercase;
}

/* --bp-file-grid: the five fixed columns (64/64/92/32 + gaps) leave the name
track nothing to render in, so filenames ellipsize to zero width. Drop the
size and type columns (their values still live in the file's own page) and
Expand All @@ -1818,34 +1679,6 @@ button.wft-card__name:focus-visible {
}
}

/* People tab: pending invites share the member-row surface + revoke control. */
.member-row__actions {
display: flex;
align-items: center;
gap: 8px;
}
/* Styling now comes from .ul-select .ul-select--sm (markup in workspace-ui.ts)
— that primitive's `appearance: none` takes full control of the box, which
turns out to be exactly what removes the old <select>-vs-<button> height
quirk (a previous attempt pinned `height: 27.5px` here because plain
box-matching CSS left a <select> rendering 2px taller than a <button> no
matter how closely font/padding/border/line-height/box-sizing were copied;
`appearance: none` plus a drawn-in caret doesn't have that quirk — see
packages/ui/src/styles.css). `.member-row__role-select` itself is now only
a JS hook (people.astro); the one thing it still needs is `width: auto` —
.ul-select defaults to 100% for stacked fields, but this select sits in the
flex .member-row__actions row next to the Remove button. */
.member-row__role-select {
width: auto;
}
.member-row__role--pending {
color: var(--accent);
}
.member-row--pending .member-row__name {
font-weight: var(--weight-medium);
color: var(--muted);
}

/* Galleries empty state — the primary element when a workspace has none. */
.ws-empty-state {
display: grid;
Expand Down
Loading