Skip to content

feat(billing): Overview and Reports - show what the bill is made of - #281

Merged
saurabh6790 merged 26 commits into
frappe:developfrom
saurabh6790:feat/billing-overview-trays
Aug 13, 2026
Merged

feat(billing): Overview and Reports - show what the bill is made of#281
saurabh6790 merged 26 commits into
frappe:developfrom
saurabh6790:feat/billing-overview-trays

Conversation

@saurabh6790

Copy link
Copy Markdown
Member

Reworks the billing overview around one rule: the card answers, the tray
explains, and only history goes to a separate page. Adds that Reports page.

Overview

The cycle figure now splits into what is already owed and what is still
estimated. The engine has always known which is which; it was thrown away
before it reached the customer.

New "Next payment" card. It names the date, amount and instrument, and where
the team's own state says the debit cannot go through, it says so before the
1st. The collection banner only ever appeared once a team was already stuck.

Subscriptions and metered services merged into one list, every row carrying
what it has cost so far this cycle. Neither card showed money before.

Each card opens a docked tray for the detail. Cards cap at five rows and hand
the rest to the tray rather than growing their own scrollbar.
screencapture-demo-billing-local-8011-dashboard-billing-2026-08-13-11_41_16

Reports

New section: spend month by month, where it went by product and region, a
statement of account, tax as we rated it, payments and refunds. CSV export,
which did not exist anywhere in billing/api before.

No downloadable tax invoice. ERPNext issues those and the sync only runs
outbound, so the statement says what it is instead of implying we can hand one
over. Refunds show the provider's reference, not an ARN.
screencapture-demo-billing-local-8011-dashboard-billing-reports-2026-08-13-11_41_47

Invoices

An invoice for a resized month was a list of durations with no order: rows
grouped by segment rather than by time, and nothing saying when. Lines now
record the window they billed and arrive in the order they happened, grouped
under the machine's own id, with a connector where a machine changed size. Six
charges for one server stop reading as six servers.

screencapture-demo-billing-local-8011-dashboard-billing-invoices-2026-08-13-12_01_53

saurabh6790 and others added 19 commits August 13, 2026 12:06
…at will stop the next payment

The engine already split a projection into what is owed and what is inferred,
and already worked out where a team's own state means the next debit cannot
go through. Both stopped at the operator surface.

get_forecast now passes the measured/estimated split through instead of
quoting a bare total, and every line carries its basis. Two new reads:
get_next_payment/get_payment_schedule say when we will charge, from which
instrument, and what would block it — in the customer's words, not the
engine's. get_cycle_costs breaks the cycle down per server and service;
get_locked_prices shows each locked rate against today's list.

Where a locked rate now sits above list, that is reported as such rather
than as a negative saving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three cards now say something the page could not say before. "This cycle"
splits its figure into what is already owed and what is still inferred, and
refuses to draw the bar at all when none of it is a guess. "Next payment"
is new: it names the date, the amount and the instrument, and where the
team's own state already decides the debit cannot go through it says so
before the 1st — the collection banner only ever appeared once a team was
already stuck. "Your prices are locked" makes grandfathering visible to
the customer it protects, including the case where the catalog has since
fallen below the locked rate.

Each opens a docked tray rather than a page: the cycle breakdown tags every
line owed or estimated, the payment schedule carries the pre-debit notices
we sent and the escalation ladder, and the price tray shows each locked
rate against today's list. One tray at a time, since the panel column is a
single slot.

The subscriptions list was quoting today's catalog rate, so a grandfathered
team saw a price it would never be charged; it now quotes the open segment's
locked rate, which is what billing actually uses. Both lists gained the
month-to-date cost of each row.

Subscriptions and metered services stay two cards. The mock merged them, but
the split is deliberate — a service has no Asset, pause/resume is server-only,
and a merged list duplicated rows at zero. The cost they were missing is
there without the merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first pass bolted cards onto the existing stack and never rendered the
page. Laid out against the design it was wrong in three ways.

The cycle figure is the headline, so it takes the full width, and the pair
beneath it is what happens to that figure next — next payment and wallet.
Three equal cards in a row said none of that.

Subscriptions and metered services are one list after all. The reason they
were split was that a service has no monthly rate and showed as a duplicate
row at zero; ranking on cost-so-far instead of a standing rate removes it,
and the rows stay typed underneath so pause/resume is still server-only.

In the breakdown tray only the inferred lines are badged. Marking the facts
as well put a badge on every row, which is noise — the estimate is the
exception, and that is what a badge is for. The hatch on the split bar was
two near-white tokens and could not be seen at all; it is now readable.

Wallet says how far the balance goes as a percentage of the cycle.

Verified in a browser this time, against a seeded team with a locked-below-
list server and a metered service: the split renders, all three trays dock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…roken

The overview answers this cycle and its trays explain it. What neither can
hold is history, so that gets its own page: spend month by month, where it
went by product and by region, a statement of account, tax as we rated it,
and refunds. Twelve months by default, three and six available, CSV export —
there was no export anywhere in billing/api before this.

Grouping reuses the revenue reports' own family resolution rather than
re-deriving it, so a customer's "cost per product" and the operator's
services revenue cannot disagree about where a line belongs.

Two things it deliberately does not do. There is no downloadable tax
invoice: ERPNext issues those and the sync only goes outbound, so the
statement says what it is instead of implying we can hand one over. And a
refund shows the provider's reference, not an ARN — that arrives on a
webhook we don't route yet, and a reference a bank won't recognise is worse
than none.

Tax buckets no longer come out labelled "None": output_tax_type's "no tax"
option is that literal string, truthy, and it was reaching customers as the
name of a tax.

First run got its own pass. A team with no invoices met a page of zeroes and
two links that opened empty trays. Reports now shows one first-run state
instead of five empty cards; the cycle card drops the breakdown affordance
when there is nothing to break down; next payment stops being a door; and
the wallet describes the wallet rather than the cycle next to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ne says what happened

Three things from the original list that had not landed.

delete_payment_method dropped our row and nothing else. For UPI Autopay that
left a standing debit permission alive at the bank while the customer believed
they had withdrawn it — the failure here costs them money rather than
convenience. The mandate is now revoked at the gateway first, and a refused
revoke keeps the row, so what the customer can still see is what the bank
still honours.

A failed payment showed the gateway's own words. "authentication_failed" and
"do_not_honor" tell a cardholder nothing they can act on, so declines are said
plainly — "Your card has expired", "There wasn't enough balance" — with the
raw wording kept underneath for anyone quoting it to support. An unrecognised
code says only that we couldn't complete the payment; inventing a specific
reason we do not have would be worse than being vague.

Ambiguity is checked before the wording, and the test that caught this is the
reason it is: timeout sat in both tables and the specific one won, so a
payment that may yet settle was being called failed. That is how an invoice
gets paid twice.

Payments also now have a card on Reports. The export shipped without it, so
the history was downloadable but not readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things stopped it, each only reachable on a fresh site — which is the one
case the seeder exists for, so none of them had ever been hit.

An Atlas Instance links to a Region and the region was upserted after the
instance. A capture that follows a decline on the same invoice kept the
default retry number, and the attempt key is hash(invoice, retry_number), so
it collided with the decline it was meant to follow; the duplicate-charge
scenario collided the same way. And the scenarios put a team on plan-8vcpu
while the plan ladder stopped at 4.

The seed now also raises catalog prices as its final step. Every team already
holds a locked rate by then, so the rise lands on top of the locks and leaves
running subscriptions untouched — which is the whole point of grandfathering
and, until now, something the demo could not show because nothing had ever
gone up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…visible

bg-ink-* is not a thing. ink-* is a text-colour family in this preset and
backgrounds come from surface-*, so every bar drawn with bg-ink-gray-8 had no
fill at all. The billing charts were blank, and so was the servers usage meter
and the status dot on the resource card, which had been that way already.

Bars now fill with surface-gray-10 — the same value frappe-ui's own Progress
uses, and monochrome by house convention, where colour means state rather than
category. The estimated portion stays a hatch of the same ink instead of a
second hue. Over an allowance the bar goes surface-amber-7, the first amber
with enough contrast to read at that height; the -2/-3 tints are banner
backgrounds and disappear.

The tint scale bit again on text. ink-red-3 is #ffd8d8 and ink-green-3 is
still outstanding, and the price-protection saving were all set in them. Moved
to the -7/-8 steps.

A month with no spend now draws a baseline rule rather than a two-pixel stub
that read as a rendering fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An overview that scrolls inside itself stops being an overview. The page
already scrolls; a second scrollbar within a card hides rows behind a gesture
nobody makes, and the card's own height stops telling you anything about how
much is in it.

Cards now show five rows and hand the rest to the docked tray they already had
a slot for: "what you're paying for" on the overview, and the statement and
payment history on reports, which had been rendering a year of rows inline and
an expand-in-place toggle respectively. Reports gained the tray column the
overview has, one open at a time.

The row moved into its own component and the list into a composable, because
the card and the tray draw the same rows from the same source — rendering them
twice is how the two quietly stop agreeing about a total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panels were mounted and the state existed, but the two cards were never
given their @OPEN handler, so both View all buttons did nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A bar under every row read as three heavy underlines rather than a comparison,
and a near-black fill spanning the card is the thick saturated block every
chart guide warns about — loud, and no easier to read for it.

Share of a total is part-to-whole, so it is one stacked bar: segments in a
sequential ramp, darkest for the largest, a surface gap between them so the
divisions hold at any width, and the row list below acting as its legend. The
percentages already do the precise work; the bar only has to carry the shape.

The ramp starts a step down from the near-black end, because at full width that
tone is a slab. Past four slices the tail folds into "Other" instead of
inventing a fifth grey nobody can tell apart. A single slice draws no bar at
all — it is 100% of itself, and the row beneath already says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every attempt was dated from `creation` — the moment the row was written. For a
backfilled or migrated attempt that is simply the day it was imported, so ten
months of payments all read as having happened today, and sorting by the same
field left them in insert order rather than chronological.

The list now resolves completed → initiated → creation, the precedence the
invoice timeline already used, and sorts on that. Ten months of history reads
as ten months, and a decline followed by its successful retry the next day
reads in that order.

The demo's transaction references were built from the invoice name, so a
capture showed as "pi_INV-2026-02-00011" beside genuinely opaque ids. Gateway
references are opaque; the demo's now look like it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed afternoon

Every invoice finalised at 08:00 on the last day of its period and every card
settled at 09:00 on that same day — for every team, every month. A bill cannot
be raised, let alone collected, before the month it covers has ended, and ten
identical timestamps are the giveaway that nothing here really happened.

Invoices now open on the 1st after the period closes, which is when the run
would actually raise them, and collections land on or shortly after that, at an
hour scattered off a hash of (team, period): stable across re-seeds, so the
demo is reproducible, but no longer a grid.

The anchor moved with it. It was frozen at 2026-06-01, so a demo seeded in
August still called June the open month: the spend chart trailed off into two
empty months and the current cycle disagreed with what the servers were
accruing. The open month is now the month the seed runs in, and the nineteen
dates that hung off the old one derive from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four figures across the top are the working — opened owed, charged, settled by
credits, settled by card. What the customer actually came for is the number
those add up to, and it was set in the same weight as an invoice row with red
type doing all the work. It read as list.

It now sits in its own tinted block between the working and the invoices, at a
larger weight, with an icon so the state is not carried by colour alone. With
nothing owed it says so plainly in neutral rather than showing a red zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…us colours

The badge trailed straight after the period label, so its position moved with
the month's name — "August 2026" and "May 2026" put it in two different places
down a column of five rows. It now sits in its own column between the period
and the amount, the same three the Invoices list uses.

The colours were wrong too, and wrong in a way that inverted the meaning: Paid
was green and Open was blue, where the shared invoiceTheme keeps Paid grey and
Open orange precisely because colour is reserved for the states that need
attention. Both surfaces now read the same status the same way, from the same
resolver rather than a second table that had already drifted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r as everything else

The badge trailed the amount, so its position moved with the figure. It now
sits in its own column between the amount and the date, the same three columns
the Invoices list and the statement use.

Status label and theme moved into lib/status beside the invoice resolver — this
was the third local copy of a status map in these components, and it had drifted
the same way the last one did: Paid in green, Failed in red, where the house
rule keeps the ordinary outcome grey and spends colour on the states that need
attention.

Two things left the card for the tray. The gateway's own wording said the same
thing as the plain-language line directly beneath it, and the transaction
reference truncated mid-hash — a half-shown id cannot be quoted to anyone, so it
is better absent than teasing. Both are in the tray, in full.

The statement tray listed oldest-first, disagreeing with the card that opens it.
Newest first, like every other history surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The staged resize stepped up the plan ladder from a team already on the largest
plan. `_plan_after` clamps at both ends, so the "upsize" and the "downsize" both
resolved to the size it started on: six invoice lines all reading 8 vCPU / 16 GB,
differing only by a rate the invoice never showed. Nothing about it was a resize.

It now steps whichever way the ladder allows — down from the top — and stages
nothing at all where there is only one rung, rather than seeding a change from a
size to itself.

The invoice lines carry their rate too. A mid-month resize splits one server
across several segments and the price is the only thing that distinguishes them,
so leaving it off made a legitimate split unreadable as well.

Acme now reads as what it is: 13 days at the locked 9,360, half a day down on
4 vCPU at 6,000, then back up — at 12,000, because coming back up re-locks at
today's catalog price rather than the rate it held before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An invoice for a server that changed size mid-month was a list of durations
with no order and no "when": six lines saying "13 day(s)" and "16 hour(s)",
grouped by segment rather than by time, so the whole-month block printed ahead
of the hours that preceded it. Nothing on the line said what had changed or
when, and the reader had to reassemble the month themselves.

Each line now records the window it billed and the lines come back in the order
they happened, so one server's month reads straight down:

  8 vCPU   1–13 Aug              9,360/mo
  8 vCPU   14 Aug, 00:00–20:00   9,360/mo
  4 vCPU   14 Aug, 20:00–24:00   6,000/mo
  4 vCPU   15 Aug, 00:00–08:00   6,000/mo
  8 vCPU   15 Aug, 08:00–24:00  12,000/mo
  8 vCPU   16–31 Aug            12,000/mo

Every window closes where the next one opens, which is the property worth
holding onto — a gap or an overlap in that chain is a billing error, and it is
now visible as one. Invoices issued before the window was recorded still say
how long they ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With one server the receipt read fine. With three it did not: every line was
labelled by its plan, so two machines on the same plan were indistinguishable,
and the lines arrived in whatever order the subscription query returned — newest
machine first, its resize chain interleaved with everything else.

Lines now carry the machine they were billed for and come back grouped by it,
chronological within each group, so one server's month reads straight down and
the next server starts below it. The receipt gives each machine a heading and
its own subtotal, and the panel's inner scroller is gone — it clipped the
receipt mid-row as soon as there was more than one server to show.

The demo was hiding this. A team's scenario lists its instances, but the build
grouped them by cluster and kept only the first plan in each, so "3 instances
across 1 region" provisioned exactly one machine and the consolidated invoice it
exists to demonstrate had a single line on it. One subscription per listed
instance now, each named so a grouped receipt has a heading worth reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… actually used

The staged pair went down first and then back up, which is not a thing anyone
does. Capacity gets absorbed by a bigger machine for a busy spell and given back
afterwards, so the demo team now starts mid-ladder and the resize runs that way.

It also makes the consequence visible: the team holds 4 vCPU at a grandfathered
4,680, takes 8 vCPU for twelve hours, and comes back to the same 4 vCPU at 6,000
— the resize re-priced it, and returning to the size it started on does not
return the rate it had. That is what ADR 0010 says happens; it was simply never
demonstrable before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Fix All in Greploop

Reviews (3): Last reviewed commit: "fix(billing): scope the rate disclosure ..." | Re-trigger Greptile

Comment thread central/billing/api/dashboard/catalog.py Outdated
saurabh6790 and others added 5 commits August 13, 2026 12:18
…onfirm

A resize re-prices at today's catalog rate — that is ADR 0010 and it stays. What
was missing is that nobody said so. The picker showed the new size's price and
never mentioned that this server is held below today's list, or that the resize
gives that up permanently, including a resize back to the size it is on now.

The resize picker now carries the disclosure: what this server pays, what the
same size lists at today, and how much a month sits between them. It appears
only where there is something to lose — a rate at or above list has nothing to
warn about.

Policy is unchanged and revisited when Commitment lands, which is the mechanism
built for price certainty across resource changes (ADR 0001) and where this
belongs long-term.

The list-rate resolver moved out of the spend module rather than being copied a
third time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… known by

The receipt grouped by server but headed each group with a friendly name only.
That name is optional, can repeat, and is not what anyone looking the machine up
would search for — an Asset is named by its resource_id, and that hash is what
the cluster, support and the logs all know it as. The heading now carries it
beside the name, and shows only the id where there is no name to add anything.

Groups are ordered by spend rather than by that id. Contiguity is all the id
ordering was buying; a hash on screen is arbitrary, and "which machine costs me
most" is the question a receipt gets opened with.

The demo named its machines srv-acme-corp-1, which hid the fact that the
grouping key is opaque. They are shaped like the real thing now — deterministic,
so a re-seed is stable — with the friendly name kept alongside, which is the
richer of the two cases to look at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A resized server takes six rows on the receipt, and under a heading they still
read as six separate things — the first impression of the invoice was of a fleet
rather than of one machine that changed size twice.

The section eyebrow now says how many machines there are, so the count is read
before the rows are, and each machine's rows are joined by a connector: they are
that machine's history, and the id at the top does not change while the plan
under it does. Machines that did not change during the period get no connector —
there is no sequence to draw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ponent

They are the same list at two moments — what the month has cost so far, and what
it cost once billed — and they were two hand-rolled copies. The tray was still a
flat run of rows, so the invoice grouped by machine and the tray did not, which
is exactly how a customer ends up comparing two screens that disagree about how
many servers they own.

ChargeBreakdown now owns the shape both use: machines counted in the eyebrow,
each one's charges under the id it is known by, a connector where a machine
changed size during the period, and services below. The tray passes show-basis
so its inferred lines still carry the Estimated badge; a receipt has nothing to
mark, because by then none of it is inferred.

Three status maps, a row renderer and now a receipt have been de-duplicated this
way; each had already drifted before it was noticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It advertised as a benefit something that is only how billing works: you pay the
rate you were shown. Presenting that as a monthly saving implies the customer
pulled something off, and invites the one behaviour nobody wants them thinking
about — waiting for a price drop, or buying ahead of one.

It was also unstable as a promise. The figure inverts when the catalog falls
below a locked rate, which is why it needed an above_list case at all; a card
that says "saving 1,890" one month and goes quiet the next is worse than never
having said it.

The locked rate still appears where it changes a decision — on the invoice line
that charges it, and in the resize warning that says what a resize gives up.
Disclosure at the point of consequence, not a badge on the dashboard.

If it comes back it belongs to Commitment, where a customer has actually traded
something for the price and the benefit is real (ADR 0001).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@saurabh6790
saurabh6790 force-pushed the feat/billing-overview-trays branch from 57ae379 to 392eeaf Compare August 13, 2026 06:54
saurabh6790 and others added 2 commits August 13, 2026 12:28
A projected total on its own answers "how much". The question the page gets
opened for is "is it going up", and one month cannot answer that — so the
forecast now carries what the month before it came to, and the card reads
"12,203.98 more than July" under the figure.

Compared like for like: a full projected month against a full billed one, both
inclusive of tax. Under a percent either way is noise rather than news and says
"about the same". A team with no bill last month gets nothing — comparing
against zero would read as though spend had exploded — and a cancelled invoice
is not a bill anyone paid, so it does not count as the month before.

Neutral ink, not red. A bill going up is information, not a fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…scription

Reported as a P1: the helper took a bare subscription id, passed it to
active_segments unscoped, and returned that subscription's locked and current
rates. It was briefly whitelisted too, so any authenticated caller could read
another tenant's price by guessing an id.

The whitelist was already off it: the decorator meant for get_composed_config
had landed on the helper because it was inserted between the two, which also
silently unexposed get_composed_config and is why the resize picker had stopped
loading. That is fixed in the commit that introduced it.

This scopes the read itself. The one caller resolves the subscription from a
team-scoped lookup and could be trusted, but a helper holding tenant data should
not depend on its callers' manners, and the decorator slip is proof of how
little it takes to expose one.

Two regressions guard it: the function is not whitelisted, and it returns
nothing for a subscription belonging to another team.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@saurabh6790
saurabh6790 force-pushed the feat/billing-overview-trays branch from 392eeaf to c5a8d57 Compare August 13, 2026 07:06
@saurabh6790
saurabh6790 merged commit e147c1e into frappe:develop Aug 13, 2026
4 of 5 checks passed
sadiqxansari added a commit to sadiqxansari/frappe-central that referenced this pull request Aug 13, 2026
SidePanel is a 24rem column that slides in beside the content, hosted as the
last child of a `flex h-full` row. Once pages hand their scroll to the shell
there is no such row on mobile, so the panel stacked below the content at a
fixed 384px on a 375px viewport.

Invoices got a route because a receipt is a destination. The rest — wallet
history, role capabilities, and the four billing trays — are peeks opened from a
card, so they don't want URLs; they want the panel to stop pretending it can
dock. Below `sm` it presents as a full-screen sheet, and every consumer gets
that from one place.

A sheet is modal in a way the docked panel never was: it covers the page rather
than sitting beside it. So on mobile it announces as a dialog, takes focus when
it opens and gives it back when it closes — otherwise Close leaves focus on a
row that is no longer rendered. The Escape guard skips the panel's own root,
or the role it just gained would match the "a stacked dialog owns Escape first"
test and Escape would stop working entirely.

The wallet ledger had its own scroller nested inside the panel's — harmless
while the panel was a short column, a trap once it is the whole screen. Same
nesting frappe#281 removed from the invoice receipt.

RoleCapabilitiesPanel teleported to #team-page-aside, which existed nowhere in
the repo: it had never rendered, on any viewport, since the commit that
introduced it. Adding the target switches it on — on desktop as well as mobile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sadiqxansari added a commit to sadiqxansari/frappe-central that referenced this pull request Aug 13, 2026
SidePanel is a 24rem column that slides in beside the content, hosted as the
last child of a `flex h-full` row. Once pages hand their scroll to the shell
there is no such row on mobile, so the panel stacked below the content at a
fixed 384px on a 375px viewport.

Invoices got a route because a receipt is a destination. The rest — wallet
history, role capabilities, and the four billing trays — are peeks opened from a
card, so they don't want URLs; they want the panel to stop pretending it can
dock. Below `sm` it presents as a full-screen sheet, and every consumer gets
that from one place.

A sheet is modal in a way the docked panel never was: it covers the page rather
than sitting beside it. So on mobile it announces as a dialog, takes focus when
it opens and gives it back when it closes — otherwise Close leaves focus on a
row that is no longer rendered. The Escape guard skips the panel's own root,
or the role it just gained would match the "a stacked dialog owns Escape first"
test and Escape would stop working entirely.

The wallet ledger had its own scroller nested inside the panel's — harmless
while the panel was a short column, a trap once it is the whole screen. Same
nesting frappe#281 removed from the invoice receipt.

RoleCapabilitiesPanel teleported to #team-page-aside, which existed nowhere in
the repo: it had never rendered, on any viewport, since the commit that
introduced it. Adding the target switches it on — on desktop as well as mobile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sadiqxansari added a commit to sadiqxansari/frappe-central that referenced this pull request Aug 14, 2026
SidePanel is a 24rem column that slides in beside the content, hosted as the
last child of a `flex h-full` row. Once pages hand their scroll to the shell
there is no such row on mobile, so the panel stacked below the content at a
fixed 384px on a 375px viewport.

Invoices got a route because a receipt is a destination. The rest — wallet
history, role capabilities, and the four billing trays — are peeks opened from a
card, so they don't want URLs; they want the panel to stop pretending it can
dock. Below `sm` it presents as a full-screen sheet, and every consumer gets
that from one place.

A sheet is modal in a way the docked panel never was: it covers the page rather
than sitting beside it. So on mobile it announces as a dialog, takes focus when
it opens and gives it back when it closes — otherwise Close leaves focus on a
row that is no longer rendered. The Escape guard skips the panel's own root,
or the role it just gained would match the "a stacked dialog owns Escape first"
test and Escape would stop working entirely.

The wallet ledger had its own scroller nested inside the panel's — harmless
while the panel was a short column, a trap once it is the whole screen. Same
nesting frappe#281 removed from the invoice receipt.

RoleCapabilitiesPanel teleported to #team-page-aside, which existed nowhere in
the repo: it had never rendered, on any viewport, since the commit that
introduced it. Adding the target switches it on — on desktop as well as mobile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant