Commit graph

6 commits

Author SHA1 Message Date
Daniel Han
8b4a5ee07d
Count a capped overlay stack by what it reads, not by how the cap is spelled (#9253)
Three tests assert every bottom-right overlay stack caps itself, and counted the
literal `maxHeight: stack.maxHeight` in provider.tsx to do it. #9246 keeps the cap
but wraps it -- `maxHeight: railMaxHeight(stack.maxHeight)`, which is the same
value plus a constant gutter so the rail's overflow clip stops slicing the shadow
off its bottom card -- and all three went red against a PR that had not removed a
single cap.

The tests are right about what they care about and wrong about how they check it.
This file already made the same mistake once and fixed it the same way; the
comment two lines below one of these assertions says so:

    Counted by the layer they sit on, not by a literal z-index: the
    overlay rail reads its depth from Z_LAYER now.

_capped_stacks matches the cap being DERIVED from stack.maxHeight, through an
optional wrapping call. That still fails on everything the count was protecting,
verified by mutation: the cap dropped entirely, the cap hardcoded to a number so
it no longer tracks the measurement, and only one of the two stacks capped -- the
exact bug the "== stacks" comparison exists to catch. #9246's wrapped form passes,
and its branch goes from 3 failures to 187 passed with this test alone swapped in.

Nothing about the assertions' subject changes. A cap that stops reading
stack.maxHeight still fails.
2026-08-18 22:03:10 -07:00
Daniel Han
b6c3229a9e
Keep the two floating panels out of each other's corner (#8249)
* Keep the two floating panels out of each other's corner

The Live resource monitor and the API monitor panel both default to the
bottom-right corner, and #8199 moved the monitor from z-50 to z-[9999] to get it
over the notification stack. The API panel stayed at z-50, so from then on the
monitor painted over that panel's rows and its "Expand to full monitor" button,
and a monitor resized across the viewport -- which the Windows UI smoke does on
purpose -- hid the panel completely, Close button included.

Raising the API panel too would only move the argument up a layer. Studio already
solves this in this exact corner and does not use z-index for it: the notification
stack does not outrank the monitor, it steps over it, reading the boxes the monitor
and the chat composer publish to the monitor frame store. The API panel now does
the same thing one rung further in.

  * It reads every published box but its own and takes the first free anchor:
    the corner it shipped in, else the same column stepped over whatever is in
    the way, else another corner. A free corner means nothing moves.
  * It publishes its own box, so the notification stack steps over it as well.
    That was a second collision -- a passive status card at z-[9998] sat on a
    panel at z-50 -- and it goes away for free.
  * Dragging it freezes the placement. A user who wants the panels overlapping
    is not argued with. The drag offset is folded back into the anchor on
    release, as the monitor does, so the published box stays honest and a window
    shrunk afterwards still pulls the panel back on screen. Neither panel keeps
    a position across reloads, so a panel stranded off the edge has no way back.

Geometry cannot answer the case the smoke creates, because a monitor filling the
viewport leaves nowhere clear. So the two panels now share one layer and the one
the user touched last comes forward, with one override: a panel with no pixel
showing cannot be clicked, so it cannot be raised by that rule, and it comes
forward on its own. Only the API panel asks for that -- it is the one that opens
and places itself. With the monitor maximised the panel lands bottom-left, which
is the corner that leaves the monitor's own Close button and resize grip free.

The z-indexes themselves move into studio/frontend/src/lib/z-layers.ts, which is
the first place the ordering is written down rather than inferred from six files:

    OVERLAY_STACK 9000 < FLOATING_PANEL 9100 < FLOATING_PANEL_TOP 9101
                       < STARTUP_SCREEN 9999 < TOOLTIP 999999

Renumbering the top band only. Nothing else in the tree sits between 121 and
999998, so every relationship with every other surface is unchanged; the two
notification stacks and the two panel containers move onto the constants, and the
startup screen and tooltips keep their classes with the test pinning them to the
scale. The in-page band -- dialogs, sheets, dropdowns, the Tauri titlebar, all on
Tailwind's own scale at 50 to 120 -- is deliberately left alone: putting modals
over the notification stack is a real question, but toasts above modals is the
usual convention rather than an accident, and the titlebar half of it needs a
desktop build to verify.

reads z-layers.ts instead of a class literal and still asserts the panels beat the
stack and stay under the startup screen and tooltips, and the Windows UI smoke's
drag-resize-close on the monitor passes.

Mutation-tested, sixteen, all caught: never stepping over anything; the refuge
corner preferring the right-hand side; the panel climbing into the top chrome;
taking the first candidate whatever it covers; dodging only the first published
box; "fully covered" reading as merely overlapping, and a strip left showing on
each of the four edges; a hand-placed panel not being pulled back on screen; a
hidden panel staying hidden; raising notifying when nothing changed; every panel
claiming the front; the stack going back over the panels; the front panel
climbing two steps; a named layer dropping into the in-page band; the monitor
hard-coding its z again; one notification stack drifting off the named layer; the
startup screen dropping below the panels.

* Studio: keep the panel's refuge on the side it actually came from

The refuge ranking read the side back out of the candidate's left
coordinate, but a 400px panel anchored right in a 768px window sits at
left=352, which is left of the midpoint. The bottom-right anchor was
therefore ranked as a left-hand refuge, tied with the real one, and won
on order, so with the monitor over the whole viewport the panel stayed
exactly where it was: on top of the Close button and the resize grip
this fallback exists to keep reachable. Each candidate carries the
corner it came from now.

* Studio: count the overlay rails by their z-layer, not a literal z-index

Two pins looked for the class string 'z-[9998] flex flex-col items-end
gap-2' to find the bottom-right rails and count them. The rails read
their depth from Z_LAYER now, so both counted zero and passed vacuously
on one assert and failed on the next.

* Studio: find the overlay rails without the literal z-index

The two remaining pins split provider.tsx on 'fixed right-4 z-[9998]'
to reach the rail's class list. The rails read their depth from Z_LAYER
now, so that literal is gone and both splits found nothing.
2026-08-11 05:57:13 -07:00
Daniel Han
a860357c5f
Studio: stop the update banners printing over their own buttons (#8367)
* Studio: stop the update banners printing over their own buttons

On the chat routes the composer publishes its box to the frame store, so
stackGeometry caps the overlay rail. Nothing in the rail was shrink-0, so
the cap came out of the app-update card, and the release notes, whose
collapsed summary had no overflow containment, were painted over the
"Show release notes / Remind me later / Update" row. Train and Model hub
publish no box, get the full viewport and were always fine.

The notes are now the only part of a card that gives up height, and they
clip while doing it. The card floors at 8rem, the height of the same card
with its notes closed, so the row of buttons survives. The rail scrolls
if even that does not fit, instead of spilling the cards over the page.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the comments added by this change

* Studio: hold the failure card's height, keep the rail measurement honest

Three fixes from review, all measured in a browser against the same two
Studios the PR's evidence came from.

The desktop failure card has no notes panel, so there is nothing in it to
give up and min-h-32 is not its floor: under it the diagnostics and the
retry button are what get clipped. It holds its height instead and the
rail scrolls.

The rail's shadow gutter is now horizontal only. useStackGeometry reads
scrollHeight off that node, and vertical padding was counted into it, so
the stack asked for 24px it does not occupy: at the 83px-of-room case the
geometry pinned in monitor-stack-inset.test.ts, an 80px card measured as
104 and the rail lifted over a composer it fits under. Measured on the
chat route the rail now reports 275 for a 128 + 8 + 139 stack. The cap
has to stay on that same node, since measure() lifts it there to read the
natural height; moving it to a parent makes the read the placement's own
output, which oscillates between lifted and not.

The layout suite's bootstrap logged in with STUDIO_OLD_PW outside the try
that exists to tolerate an already-rotated install, so a rerun, or the
studio-ui-smoke job where an earlier suite rotates it first, died on a
401 before reaching that handler. It is wired into that job now, which is
the case that needed it.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: floor the update card for a wrapped action row, keep the rail scrolled

Second review round, all three measured in a browser.

The card's floor was one number, and it is two. At a 390px viewport the
action row wraps onto a second line and the card needs 184px with its
notes closed, not 128, so a tight rail squeezed it to 128 and its own
overflow-hidden cut both buttons off. Measured: with the single floor,
Remind me later and Copy command are clipped away entirely at 390x500.
It is min-h-48 under sm and min-h-32 above it now.

The rail scrolls, and useStackGeometry lifts its cap to read the natural
height. An uncapped box does not overflow, so that lift clamped scrollTop
to 0 and restoring the cap did not put it back: a reader scrolled down to
the download list was thrown back to the first banner by any descendant
resize. Measured 33 -> 0 at 390x500 and 24 -> 0 at 921x534. The
measurement saves and restores it.

The layout suite counted a vanished element as a pass, because clip()
returns None for something entirely hidden and both helpers read None as
"fine". It now fails on a missing box and checks the controls against the
card's inner surface, which is the element that actually clips them; the
root above it is overflow-visible and was clipping nothing. Added 390x500,
where a wrapped action row meets the floor. 474 checks, and the six that
catch the floor bug fail without the fix above.

test_update_release_notes.py pinned the old arrangement, where the update
card was the one that absorbed the cap. Updated to the new one: the card
floors and the notes yield, the download list still shrinks, and the rail
scrolls past what is left.

* Studio: key the update card's taller floor to the card's own width

480px is where the card reaches its 448px max width. Below it the card
narrows and its action row can wrap, so it needs the 12rem floor; at or
above it the row fits on one line and 8rem is right. The sm breakpoint
put the taller floor on the whole 480 to 639 band, where the card is
already at its full width and the row does not wrap, and a capped rail
then had 64px less to give the banner underneath. Measured wrap threshold
is a 404px card, so 480 keeps a margin for wider text.

* Studio: tighten the update banner layout comments

* Studio: let the overlay rail take pointer input while it is scrolling

pointer-events-none keeps the rail click-through, but it also takes the
rail's own scrollbar with it, and only the cards inside opt back in. Once
the rail is the scroller, that leaves the cards under the fold reachable
by a wheel over a card and by nothing else: the scrollbar cannot be
dragged, and a wheel over the gutter scrolls the page behind it.

useStackGeometry now reports whether the stack is actually scrolling,
measured on the capped box rather than by comparing the natural height
against the cap. Those are different questions: under the cap the cards
give up height of their own, so a stack that asks for more than the cap
can still fit inside it, and at 1280x830 the natural-height compare
turned the rail pointer-interactive with nothing to scroll to.

Measured on a live Studio, new chat with both banners up: 1440x900,
1280x830 and 390x844 do not scroll and stay click-through, with a click
on the gutter landing on the thread underneath; 921x534, 768x500 and
390x500 scroll by 24, 33 and 97px and take pointer input. The layout
suite is 558 checks, 0 failed.

* Studio: read the overlay stack's z from its unconditional classes

The layering pin matched the whole authored class literal, which started
with pointer-events-none. That class is applied conditionally now, so the
pin found no stack at all and passed its own assert instead of the z
comparison it exists for.

* Studio CI: give the UI smoke job room for the banner layout step

The job was finishing at 22m30s of its 25 minute budget on main, so the
new banner layout step took it past the limit: the job was cancelled
mid-step and the three image-model steps after it never ran at all.

Budget raised to 30, and the step itself made cheaper. It booted 24
pages and waited a flat 9s on each for cards that mount on a 5s and a 1s
timer; it now waits for the cards themselves, with the old figure as the
ceiling rather than the wait. Same 558 checks, 0 failed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: scale the update card's floor with the UI font size

The floor was a browser measurement taken at the default 15px type: 8rem
for one row of actions, 12rem once the card was narrow enough to wrap
them onto two. Settings > Appearance goes to 20px, and at that size the
row wraps at every card width, so the notes-closed card is 209px against
a 128px floor. Measured on a live Studio at 20px, the capped rail took
the difference out of the card and the card clipped its own buttons:
Remind me later and Copy command lost 22px of 42 at 1440x900 and 1280x830
and all 42 at 921x534 and 768x500, with Show release notes losing 14.

The floor is now written against --ui-font-scale, one value rather than
two, so it tracks the preference instead of one point on it. At 20px it
resolves to 256px and nothing is clipped at any of the six viewports.

The Tauri failure card gets a scroller for its clipboard fallback. It has
no notes to give up and every child is shrink-0, so once the message and
the diagnostics box push it past the card's own viewport cap the card
clips them, and the rail cannot scroll to what an inner max-height hides.
The report the reader is being told to select and copy was the part that
went missing.

The layout suite grew a pass at the 20px maximum, on the two viewports
that squeeze the rail hardest, and it separates the two clips it was
conflating: the card may never clip its own controls, while the rail may
hold them under a fold as long as scrolling brings them back. Restoring
the old floor fails the new pass. 806 checks, 0 failed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: let the corner stack cover the composer rather than clip itself

In a window too short to hold the update cards above the composer there is
no arrangement that both dodges it and shows them whole. The stack was
choosing to dodge and then clipping itself against its own cap, which is
what the before/after evidence showed at 921x534 and 768x500: the
llama.cpp card sliced off at the rail's edge, reading as a card that had
slid behind the page.

Those two ways to lose are not equal. A clipped card looks broken; a card
over the composer looks like a card over the composer, is on top rather
than behind, and carries a dismiss button and a Remind me later.

A published box can now say it is coverable, and only the chat composer
does. The stack still dodges it whenever dodging leaves room for the
cards; only when nothing fits does it drop the coverable boxes, take the
corner and paint over them. The Live monitor is not coverable, which is
the whole reason this store exists: its Close button and resize grip have
to stay clickable, and a coverable composer beside it does not extend it
any permission.

* Studio: decide the corner stack's fallback on the cards' floor, not their wish

The covering fallback asked whether a placement could hold the stack at
the height it would PREFER. The cards are allowed to give up their notes,
so a placement a few pixels short of that still shows every one of them,
and covering the composer to win those pixels is the worse answer. At
1280x830 a 3px shortfall did exactly that: the stack abandoned a dodge
that fitted and painted over the composer.

The measurement now reads both ends. Cap lifted gives the height the
stack wants, cap squeezed to zero gives what its cards refuse to give up,
and the fallback tests the second. Dodging wins wherever the cards fit at
their floor; the corner is taken only where nothing fits at all.

* Drop the version from the collapsed notes summary's label for PR #8367

The notes describe a release, not the version being offered, and #8352 makes
that explicit by keying them on the release tag instead. Naming the offered
version here would announce 'version 2026.8.12' for v0.1.61-beta's notes.

* Studio: sweep the banner layout across resolutions, engines and a restore

The matrix was six viewports on one engine at one type size, which is how
two bugs got past it.

The suite was poisoning its own install. Seeding a 20px UI font size into
localStorage makes the appearance store sync it up to
/api/settings/personalization, so the Studio stays at 20px for everything
that runs after it. Locally that meant an afternoon of runs measured at
20px while reporting themselves as default; in CI the next suite in the
same job would have inherited it and not noticed. It is set on the server
now and put back in a finally.

The overflow flag was latching. It was read off the DOM, so when the
placement flipped from capped-and-scrolling to covering-and-fitting,
nothing resized afterwards to correct it and a rail with nothing to
scroll to kept the pointer input it had taken. It is derived from the
placement now: the cards absorb everything between their floor and their
natural height, so a cap below the floor is exactly when the rail
scrolls, and there is nothing left to go stale.

What the suite covers now: 23 resolutions from 3840x2160 down to
320x568, walked by resizing one loaded page, which is what a maximise, an
unmaximise and a restore all are; two park-small-and-come-back cycles,
asserted against a fresh load of the same size, since a minimised window
has no layout to photograph but a stale measurement would show on the way
back; and the card's width against the viewport, because a classic
scrollbar takes width out of the box it is on and an overlay one does
not. 1369 checks, 0 failed on Chromium, Firefox and WebKit.

The layout step now also runs on the macOS and Windows jobs, where the
font metrics and the scrollbars are the real ones rather than a Linux
runner's, and Firefox and WebKit run a cut-down `spot` scope on Linux so
a third full pass does not spend minutes re-answering the first one.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: keep the overlay rail's height probe out of a transition

useStackGeometry measures the stack by writing max-height twice and
reading scrollHeight in between. transition-property: all reaches the
rail, so each write started a transition, and a transition computes its
start value until the timeline advances: the rail computed 0px while its
inline style read back as the cap it was given, and its three cards laid
out below a zero-height box. Turning the loaded models indicator on is
what surfaced it. The probe now runs with transitions suppressed and the
restore is flushed under the suppression.

The layout suite grows a pass with the indicator up, at the two sizes
where the stack would otherwise cover the composer, checking that the
rail computes the cap it was given and that the part of the stack the
reader cannot dismiss stays off Send.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: measure the update cards' floors and only cover the composer when it helps

Seven things the review round found, all in the corner stack.

The card floor scaled the whole 12rem box by --ui-font-scale, so at the 20px
Appearance setting it asked for 256px where the card needs 209. A floor nothing
can meet makes the placement give up and cover the composer for no gain. It is
a fixed part plus a scaled part now, the shape index.css already uses for
--picker-control-h, with its own constants per card because the desktop card
carries an extra status line. Measured at every step from 15px to 20px: exact
above the action row's wrap, over-reserving below it, never under.

The fallback placement took the composer whether or not it could then show the
cards, and judged its persistent tail from the corner even when an uncoverable
monitor lifts it somewhere else. It now has to reach the floor to be worth
taking, and the tail is checked where the placement actually lands.

The height probe saved only the rail's own scroll position, so uncapping the
rail grew every scroller inside it and clamped any the reader had scrolled
past the new end: read the notes to the bottom, let a download tick, and the
list jumps. Each one is noted and put back.

The llama.cpp card hides its dismiss button for the length of an update but
kept the marker that licences covering the composer, so an update in progress
could park an undismissable card on Send.

The macOS workflow's extra-UI retry rotates the bootstrap password in its own
shell only, leaving every later step reading credentials for a server that no
longer exists. It writes them to GITHUB_ENV now.

The layout suite reset the Appearance size to the default instead of to
whatever the profile was on.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: give the narrow card its own floor and measure the tail uncapped

Two more from the review round.

Below 384px the card's action pair wraps onto a row of its own on top of
the notes toggle's, and the floor did not account for it: at the 20px
Appearance setting the card needs 259px where the wide card needs 209.
Measured at 320x400 through 320x480, where the rail's cap lands between
the two, the card shrank to the declared 209 and its own overflow-hidden
surface cut 34px off Copy command. Both cards get a narrow-width floor,
measured at every type size from 15px to 20px and never under: web 229
to 259, desktop 234 to 304. The wide desktop constants were 1px short at
the widths where its action pair still wraps, and are re-fitted too.

The persistent tail was measured after the cap went back on. Those
panels are min-h-0 with their own scrollers, so under a tight cap they
measure as almost nothing, the placement reads that as a tail it can
safely put in the corner, the corner's larger cap lets them grow, and
the next measurement says the opposite: the two placements swap back and
forth for as long as a download and an update card share the rail. It is
taken during the uncapped probe now, like the other two heights.

The layout suite gains 320x480 at 20px, which is the viewport where the
first of these bites, and judges viewport containment on the part of a
card the rail is SHOWING. A card entirely under the fold was failing for
being scrolled out of sight, which is the reach check's question, not
this one.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-11 05:53:54 -07:00
Daniel Han
c3de39652e
Source the update popup's release notes from the GitHub releases (#8352)
* Source the update popup's release notes from the GitHub releases

CHANGELOG.md had to be hand-edited before every release, and it was keyed by
the PyPI backend version while the announcement itself lives on a release
tagged with the Studio version. The two drifted: the file carried three-bullet
stubs while the release page carried the write-up.

The newest published release is the source now, so the popup follows each new
release with no file to edit and no rebuild. Only the announcement is shown:
the install instructions, the generated What's Changed list, New Contributors,
the Full Changelog line and the appended build provenance are stripped out,
wherever in the body they were written.

* Strip an install block introduced by a paragraph, not just by a heading

v0.1.471-beta writes the same sentence v0.1.43-beta puts in a `###` heading
with no hashes in front of it, so _is_upgrade never saw it and the popup kept
a stale version pin and both install commands. A paragraph has no level, so
the block it opens runs to the next heading that is not one of the platform
headings holding its commands. Run over every published release body, exactly
one output changes and the removed lines are exactly that install block.

Also raise the releases page to the endpoint maximum of 100, which costs the
same single request, and fix three assertions that could not fail: the draft
case used a tag the tag filter rejects first, so the draft filter had no
coverage; no published body carries a provenance section, so asserting its
absence proved nothing; and "refresh" appears throughout the hook, so the
retry path could be deleted with the test still green.

The comment justifying the removed desktop fallback was wrong about what
latest.json's `notes` holds. It is the static download blurb the release
workflow writes, the same text every release, not this release's body.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Bound the rate-limit deadline, not just the first wait on it

The 15 minute cap was applied to the returned TTL while the raw reset epoch
went into _rate_limited_until, so the fetch after that TTL expired answered
from the uncapped deadline and blocked release notes, Retry included, for the
whole server-supplied interval.

GitHub says not to request again before X-RateLimit-Reset, so the reset still
wins over the back-off rather than being cut to 15 minutes, which would retry
into the limit. It is now held to the hour that the unauthenticated window
actually is, so a skewed or proxied header cannot park the popup.

* Record a deadline for every refusal, not just the primary rate limit

A secondary rate limit answers 403 or 429 without X-RateLimit-Remaining: 0, so
that branch returned a 15 minute TTL and left _rate_limited_until unset. Retry
saw no lockout, dropped the cached failure and requested straight back into the
limit, which is what GitHub warns against.

Every refusal now records a bounded deadline, in the order GitHub documents:
Retry-After, which is how a secondary limit states its wait, then the primary
limit's reset, then the plain back-off when the response says neither.

* Size the releases page against the read cap, and split platforms on a slash

A release entry carries its whole body and the newest ones run about 40 KiB,
so the endpoint's maximum of 100 puts the response near 4 MiB against a 2 MiB
cap. The fetch would then fail outright with the release it wanted sitting at
the top of the page it just threw away. Back to 30, which is about 1.2 MiB at
that rate, with the arithmetic recorded in a test that fails if the page grows
past what the cap allows.

_is_platform replaced a slash with a comma but not the spaces around it, so
neither "macOS / Linux / WSL" nor "macOS/Linux/WSL" matched and an install
block written that way would have kept its commands. The separators are now
read as one thing.

* Tighten the comments this change added

Comments only, no code. Mostly the test docstrings, which had grown into
several lines of bug narrative each and now lead with the rule and keep at
most a clause of what broke, and the blocks in release_notes.py that only
restated the line below them.

The reasons bought during review are kept, shorter: why the releases page is
30 and not the endpoint maximum, why the rate-limit deadline is bounded and
why GitHub's reset beats the back-off, why releases are ordered by
published_at, and why the install block is excised where it stands.

* Refuse a desktop tag that goes backwards for PR #8352

Both updater paths compare SemVer, so v0.1.60-beta published after
v0.1.527-beta reads as older and no client on the 5xx series is ever offered
it. Desktop builds have only shipped at four tags, v0.1.526-beta through
v0.1.61-beta, so this is the first time the tag numbering has stranded
anyone, and it strands everyone installed before Aug 10.

Check the new tag against the version in the published latest.json, which is
the file a running build actually reads, and refuse a release that would
strand its own users. Warns rather than fails when the manifest is
unreachable, so a network blip cannot block a release.

* Compare full SemVer in the tag guard, and drop a bare platform install block

The guard compared only the numeric triple, so v0.1.528 or v0.1.528-rc after
v0.1.528-beta read as equal and were refused, though both are newer and the
updater treats them that way. Compare full SemVer precedence instead.

Separately, a platform heading only ended an install block that an 'Updating'
heading or paragraph had already opened, so a release heading its commands
with a bare 'MacOS, Linux, WSL:' kept them. v0.1.0-beta and v0.1.41-beta do
that, and they are the only two of the 24 published bodies this changes; all
seven platform headings in that corpus head an install block.

* Resolve the published desktop version from the newest Studio release

The guard read /releases/latest/download/latest.json, but this repo also
publishes llama.cpp prebuilts as ordinary releases: 25 of them, non-draft and
non-prerelease, so any new one becomes GitHub's latest release. Those carry no
manifest, so the read would 404 and the guard would skip itself silently,
which is the same hazard release_notes.py already documents and avoids.

Take the newest non-draft Studio tag that actually ships a latest.json, and
authenticate with the workflow token rather than spending a shared quota.

* Tighten the comments the last two rounds added

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-11 00:28:01 -07:00
Daniel Han
37f43eb742
Repin six UI contracts on what they guard, not how it was spelled (#8197)
* Repin six UI contracts on what they guard, not how it was spelled

Repo tests (CPU) is red on main with six failures. All six are source-shape
contracts whose subject was rewritten by #8082 and #8149; in every case the
behaviour they exist to protect is intact, and in two cases it is better than
what the assertion described.

Three in test_update_release_notes.py asserted the literal Tailwind class
max-h-[calc(100dvh_-_2rem)]. #8082 replaced that fixed cap with stackGeometry,
which subtracts the same 2rem when nothing is in the way and subtracts more when
the Live monitor is, so the stack dodges it instead of sitting under it. The
arithmetic is checked numerically in monitor-stack-inset.test.ts, which pins
stackGeometry(null, W, H).maxHeight to H - 32 among thirteen other cases. What
these tests can still add is that every stack element reads it, so they now
count stack containers and require a cap on each. Counting matters: there are two
such elements, browser and desktop, and capping one and not the other is the
mistake worth catching. A fourth test names the node test, so deleting the
numeric check does not quietly leave the cap unguarded.

Three in test_model_picker_contracts.py:

- The routed pick test pinned diffusionRoutePick's second argument, which was
  respelled from routeSearch.quant to routedFilename. The catalog spec is the
  third argument and is the thing the test is named for, so the second is no
  longer matched.
- The GGUF guard test required a toast.error in a branch that used to reject a
  repo-id pick. #8149 made that branch resolve the repo's own .gguf instead,
  which is the better answer. The invariant that survives is that the branch does
  something: a bare return there drops the pick with no request and no message.
- The deferred-load test required the flush effect to end with }, [active]); and
  to call handleLoadRef directly. The load body moved into runStagedLoad, shared
  with the visible path, so the dependency array grew. It now accepts either
  spelling and, when the helper is used, follows through into it.

Mutation-tested, all eight caught: cap dropped from the browser stack; from the
desktop stack alone; desktop stack stops measuring itself; node test stops
pinning H - 32; repo-id GGUF pick returns bare; routed pick drops its catalog
spec; deferred flush clears the flag and stops; runStagedLoad loads nothing. The
first two survived an earlier draft that used a plain substring check, which is
why the counting form is there.

tests/studio: 3 failed, 2795 passed. The three are
test_studio_gguf_export_script_pin.py, which fails identically on main and is
outside this job's scope.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Bound the deferred-flush match to its own effect

Keying the closing boundary on `[active` let a validly reordered dependency
array run the match past the effect and on to the next hook, where loadOrStage's
own handleLoadRef call satisfied the loader assertion for a flush that loads
nothing. Stop at the first boundary and check the deps separately.

* Check the catalog spec by position, not by presence

The capture is every argument after `wanted`, so a substring check did not prove
the spec reached the third parameter. `diffusionRoutePick(wanted, routedFilename
?? loadSpecFor(wanted, IMAGE_CATALOG)?.filename)` type-checks, passes the catalog
filename as the quant and drops the spec, which sends curated single-file
artifacts down the GGUF branch, and it satisfied the old assertion. Split the
argument list on its own commas and pin the third.

* Tighten the repinned contract comments

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothshared@gmail.com>
2026-08-08 22:32:58 -07:00
Michael Han
d74d03d350
Show release notes in the update popup, sourced from CHANGELOG.md (#7432)
* Show release notes in the update popup, sourced from CHANGELOG.md

The update banner only linked out to the online changelog, so there was no
way to see what an update contains before taking it.

Add CHANGELOG.md at the repo root as the source of release notes. Studio
reads it from the default branch, so editing the file updates the popup
without a release or rebuild, and falls back to the copy bundled in the
install when the repo is unreachable.

Notes are matched to one exact version. The popup asks for the version it is
offering and gets that section or nothing, so an older release's notes can
never appear next to a newer update. When there is no match the popup links
out to the online changelog instead.

The collapsed popup previews the top bullets with the leading sentence
highlighted; "Show release notes" expands the full notes in a scrollable
panel. Applies to both the browser and desktop banners, and the desktop
updater's own release body is used when CHANGELOG.md has no matching section.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address review: fence matching, nested bullets, BOM, updater notes field

Track the opening fence marker and length so a ``` sample inside a ````
block does not close it early and let the sample's heading be indexed as a
real release.

Preserve list indentation in the preview and take only top-level bullets, so
nested detail no longer consumes the four headline slots.

Strip a UTF-8 BOM before parsing. An editor on Windows can leave one on the
first line, which hid a section whose heading started the file.

Read `notes`/`pub_date` from latest.json in the manual Linux updater path,
with aliases for the older `body`/`date`. The workflow publishes Tauri's
field names, so the manual path's release body was always empty. Also loop
the preview tag strip until stable for CodeQL js/incomplete-multi-character
-sanitization; the value renders as text, so this is defence in depth.

* Address review: bare fence closers, HTML comments, underscores, notes URL

A closing fence must carry nothing after the delimiter, so a ```` line with
trailing text inside a ```` block is content rather than the end of it. Both
the parser and the preview extractor follow that rule now.

Skip headings inside HTML comments. A commented-out section is not rendered
by Markdown, so it must not be indexed as a release.

Strip only paired emphasis and park code spans first, so identifiers keep
their underscores: UNSLOTH_DISABLE_UPDATE_CHECK was previewing as
UNSLOTHDISABLEUPDATECHECK.

Prefer the caller's release URL over the API's generic changelog link, so the
desktop fallback points at the release page for the version being offered.

Look at the repo-root CHANGELOG.md before the packaging snapshot, and remove
the snapshot after build.sh, so an edited root file is never shadowed by a
stale copy.

Also nudge the notes container radius from 16px to 14px.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address review: comparison operators, hidden comments, remote failures

Require a name character after "<" when stripping tags. A bullet reading
"Support Python <3.15 and >3.9" previewed as "Support Python 3.9", because
the operators were consumed as if they were a tag.

Track HTML comments while collecting preview lines. A commented-out bullet
was previewed as a published change even though Markdown never renders it.

Report a remote lookup failure whenever nothing matched. The bundled
changelog cannot know a version newer than the install, so discarding the
error made an offline lookup read as "no notes were published". The hook now
treats a reported failure as its retryable error state.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address review: code-span delimiters, stale notes, retry past cached failures

Treat an HTML comment delimiter inside inline code as literal. A note reading
"Type `<!--` to begin a comment" put the parser into comment state, so every
release below it was swallowed into the entry above and became unfindable.
Applied to the preview extractor too.

Return no notes while the offered version differs from the fetched one. On
the render where the version changes, the hook still held the previous
release's notes, which the panel would show for a frame.

Let retry bypass a cached remote failure via a refresh flag on the endpoint.
Failures are cached for five minutes, so the visible Retry action could not
recover until the TTL expired. A cached success is still reused, so retries
cannot hammer the remote.

* Address review: CommonMark indentation, desktop release notes link

Allow up to three leading spaces on release headings and fences, and treat
four as indented code. An indented heading was unreachable and its notes were
appended to the release above, while an indented backtick line opened a fence
that swallowed later headings.

Link desktop release notes to the release page for the offered version on
every platform. The existing URL is built only in manual Linux package mode,
so in-app updates on macOS, Windows and AppImage fell back to the generic
changelog. The install button keeps using the manual URL.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address review: wrapped prose, autolinks, abbreviations in the preview

Accumulate contiguous prose lines into one preview item. A paragraph wrapped
across source lines renders as one block but previewed as three fragments,
which also ate the four-item limit.

Keep Markdown autolinks. <https://example.com/notes> was stripped as if it
were a tag, so "See <https://example.com/notes> for details" previewed as
"See for details".

Do not split the lead sentence at an abbreviation. "Supports several formats,
e.g. GGUF and Safetensors." highlighted only up to "e.g." and dimmed the
actual change; known abbreviations and single initials are skipped now.

* Address review: park code spans first, skip indented code blocks

Park code spans before any other inline transformation. Tags, links, images
and emphasis inside a span are literal, but the strips ran first, so "Use
`<button>` for actions" previewed as "Use for actions".

Skip lines inside an indented code block when collecting bullets. A "- pip
install ..." line in a four-space-indented block became the headline and
pushed out the real prose, though Markdown renders it as code. Continuation
lines of an open bullet are unaffected.

* Studio: skip raw HTML blocks when reading release notes

A <pre>, <script>, <style> or <textarea> block renders literally, so a
sample '## 9.9.9' heading inside one was indexed as a release and cut the
real section's body short. The preview had the same gap and listed sample
bullets as notes.

Both readers now track type 1 HTML blocks and skip their contents. Blocks
open only at the start of a line, so a tag named mid-sentence stays inline
text, and <details> is type 6 so its Markdown still parses.

* Studio: read HTML blocks the way CommonMark renders them

A fence inside a <pre> block was treated as a real fence, so the block's
closing tag was swallowed and every release below it disappeared. Raw HTML
state is now checked before fences, in both readers.

Type 6 and 7 blocks (<details>, <div>, a bare tag on its own line) run to
the next blank line, so a heading pressed against the opening tag is not a
release either. Type 7 cannot interrupt a paragraph, so prose followed by a
bare tag is unaffected.

Checked against a CommonMark reference: 20000 generated well-formed
changelogs now agree exactly on which headings are releases, and every
previewed note is text the renderer really shows.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: restore preview types dropped in the scanner refactor

The previous commit's refactor removed the Bullet and preview item
interfaces, so tsc -b failed and every job that builds the frontend
stopped there.

* Studio: fix release-notes preview and packaging review findings

Preview: a code span now closes on a run of the same length, so a note
containing backticks keeps them; thematic breaks no longer take a preview
slot; a quoted list is example output, so it stays out of the headline
bullets and is only used when a section has none of its own.

Popup: a failed lookup keeps the changelog link beside Retry, which the web
banner always offered before, and the desktop popup waits briefly for the
auto-auth token instead of recording a failure the user has to clear.

Packaging: the changelog snapshot is made by the build backend, so
python -m build, pip install . and sdist builds all ship the offline copy,
not only build.sh.

* Studio: scope the changelog fallback and hide staged sections

Installed, the levels above studio/ are site-packages, so a stray
CHANGELOG.md left there by another package outranked the bundled
snapshot. Those levels are now searched only when a checkout marker
(pyproject.toml or .git) is present, so a source checkout still serves
the editable file.

A section staged as only an HTML comment renders as nothing but was
reported as matched, leaving an empty notes surface. Notes that render
nothing now read as unpublished, so the popup links out instead.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: cover the remaining raw block forms and repository links

Parser and preview: processing instructions, declarations and CDATA are
literal like <pre>, so a sample heading or bullet inside one is no longer
read as a release. ATX headings now need a space or tab after the hashes,
matching CommonMark, so a pasted non-breaking space no longer truncates
the release above it.

Popup: the notes region follows the viewport and the card scrolls as a
backstop, so a window under about 430px high no longer pushes the title
and dismiss control off screen. Relative links in the notes resolve against
the repository instead of Studio's origin, where the renderer blocked them.

* Studio: reference-style images, empty previews and version queries

Reference definitions now resolve against the raw host when the label is
used as an image, so ![alt][arch] loads the file instead of its HTML page
on GitHub. Labels are matched the way CommonMark compares them, and a
reference written inside a fenced block does not count.

Notes that preview as nothing, such as a lone command block, no longer
leave an empty muted strip in the collapsed popup; expanding still shows
them. A version query that cannot parse is rejected up front rather than
looked up and reported as no notes.

* Studio: Markdown scanning fixes across the release notes path

Code spans are now scanned rather than matched by pattern, so a run of
backticks closes only on a run of the same length. The preview and the
link resolver share that scanner, so a link inside `a``b [x](y.md)`
stays literal in both.

Also: a closing fence may carry only spaces or tabs, so a delimiter with a
non-breaking space after it stays code in all three scanners; escaped
parentheses in a link target resolve to the literal path instead of being
mangled; the collapsed preview decodes entities the way the expanded view
renders them, while code spans stay literal; and release notes are fetched
through authFetch so an expired access token is refreshed and retried.

* Changelog: real 2026.7.5 notes, led by the AMD release

Fills the section the popup reads with the actual headline changes, so the
collapsed preview shows real content instead of placeholder notes. Leads with
AMD support and covers the 23 July update: RDNA2 and Gorgon Halo, Strix Halo
detection, RDNA4 and ROCm failure recovery, 2x faster unified memory loading,
whisper.cpp dictation, and rollback environment cleanup.

* Studio: fix release-notes text handling found by adversarial testing

Line endings are normalised first: a CRLF body from the desktop updater no
longer hides fences, so a code sample cannot become a headline bullet, and
lone CR text splits into bullets.

Preview: reference links and images render as their text, a definition line
renders as nothing, parentheses in a destination no longer truncate the
sentence, escaped punctuation stays literal, and a fence indented into a
list item is treated as the block it is.

Links: a badge resolves both its image and its outer link, indented code and
code spans that cross a line are left alone, a definition cannot interrupt a
paragraph, and image alt text no longer decides a label's host.

Also: an escaped backtick cannot open a code span, park sentinels in the
source cannot swap content, two in-flight requests for one version resolve
in order, and repeated bullets no longer share a React key.

Comment scanning no longer rescans code spans per delimiter and span lookup
is a binary search: the worst inputs measured drop from 96ms to 1ms at the
20k cap, and from 544ms to 15ms at 200k.

* Studio: parser and fetch fixes found by adversarial testing

A comment marker written in prose no longer swallows the rest of the file.
Only a comment that starts a line opens a block; one written mid-sentence is
inline HTML and hides its own line at most. This was the worst case found:
a single stray marker made every release below it unreachable and served
their notes under the newer version's heading.

Also in the parser: a closing delimiter takes its whole line, so a heading
glued after it is not a release; an exact heading is never shadowed by a
zero-padded one; setext headings are release boundaries; any heading, rule
or definition ends a paragraph; and the code-span guard is a linear scan
rather than a backtracking pattern, so 20k backticks parse in a millisecond
instead of over a minute.

Fetching: one deadline for the whole response with chunked reads, so a
trickling server cannot hold a worker for minutes, waiters give up instead
of queueing behind a stalled fetch, and identity encoding is requested so a
compressing proxy cannot produce mojibake notes. Truncated notes close an
open fence.

UI: images and the renderer's own link dialog are held inside the card,
which the shared preview's blanket max-width reset had let escape, and only
the notes region scrolls so the dismiss control stays reachable on a short
viewport.

The developer update override no longer beats the documented opt-out, and
its value has to parse as a version.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: CommonMark paragraph and block rules across the notes path

Setext detection now requires plain paragraph text above the underline. A
list item followed by --- is a list and a rule, not a heading: reading it as
one discarded the bullet and every note after it.

A backtick fence whose info string holds a backtick is not a fence, so such
a line no longer swallows the releases below it in the parser, the preview
and the link resolver.

Preview: only an ordered list starting at 1 interrupts a paragraph, an
unresolved reference keeps its brackets, a comment written mid-sentence
hides its own line at most instead of the rest of the document, a raw block
closer takes its whole line, and a code span closer after a backslash still
closes, since escapes do not apply inside a span.

Links: raw HTML blocks are literal, an escaped opener is not a link, and a
definition under a heading is a definition.

The overlay stack is capped to the viewport and both overlays can give up
height, so a long download list no longer pushes the update card's title and
dismiss control off screen.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: desktop notes by backend version, desktop stack cap, fetch budget

latest.json now publishes the backend release the desktop build pins, and
both desktop paths carry it: the manual metadata check through Rust, and the
in-app updater through the raw metadata it already exposes. The popup looks
release notes up by that version, so desktop stops asking CHANGELOG.md for
an app SemVer it never contains and falling back to the generic installer
text. Metadata without the field still parses and behaves as before.

The desktop overlay stack is capped to the viewport like the browser one,
since the download panel shares it and the card's own cap cannot see a
sibling.

The fetch budget now bounds each read, not just the gap between reads. Slow
headers followed by a slow body held a worker for 5.6s against a 3s budget;
it is 3.0s now, and a timeout is reported as one.

* Studio: keep list-nested headings out of the release index

A `## <version>` heading indented to a list item's content column is inside
that item in CommonMark, not a release boundary. Reading it as one truncated
the real release and indexed a version that does not exist.

parse_changelog now tracks the open list items by the column their content
starts at, and only counts a heading left of that column. Supporting rules,
each checked against markdown-it (commonmark preset): a marker needs
whitespace after it, so `2.0` stays a setext version; an item interrupts a
paragraph only when it has content, and an ordered one only when it starts at
1; an empty item takes one blank line; a dedented fence, break or heading
closes the item; and `- ## 2.0` is a heading inside the item.

* Studio: whole-paragraph setext headings, uppercase declarations, escaped marks

Three CommonMark conformance fixes on the notes path, each checked against
markdown-it (commonmark preset).

A setext heading is the whole paragraph above the underline, so a heading that
wraps kept its version only on the first line while the parser read the last:
`2026.7.5 - Release` over `July 25` left that release unindexed and its notes
unreachable. The parser now tracks every line of the open paragraph, including
lazy continuations, and stops at whatever really interrupts it: a quote marker,
a bullet, or an ordered marker starting at 1.

A type 4 HTML block needs an uppercase letter after `<!`, so prose mentioning
`<!note` was hiding every release below it until the next `>`.

In the link resolver, `\![alt][label]` renders as a link, so its definition
resolves to the file's page on GitHub rather than the raw-content host.

* Studio: the preview needs the uppercase declaration rule too

The backend parser stopped treating `<!note` as an HTML block, but the
collapsed preview still did, so prose mentioning one emptied the preview of
every bullet below it while the expanded notes rendered them. A shipped test
now pins the two to the same rule.

* Treat an empty HTML comment as closed and always release the changelog fetch flag

<!--> and <!---> are complete comments in CommonMark: the closer overlaps the
opener, so searching for --> past the opener never found it and the scanner
stayed in comment state for the rest of the file. An empty comment used as a
section marker hid every release below it, in both the backend parser and the
frontend preview.

get_remote_changelog cleared its single-flight flag only after except Exception,
so a BaseException stranded it and every later caller waited out the full
deadline for the life of the process. Move the release into a finally.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Compare resolved changelog paths instead of a hardcoded checkout name

The ordering assertion matched the string suffix /unsloth/CHANGELOG.md, so it
raised StopIteration in any checkout not literally named unsloth, and on
Windows the separator is a backslash so the suffix never matched there either.
Both are unrelated to the ordering under test. Verified failing on
ubuntu-24.04, macos-14-arm64 and windows-2025 alike, and passing after.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Scan backtick runs once instead of rescanning the suffix per opener

Every unmatched opener rescanned the rest of the line and the outer loop then
advanced by a single run, so a line of runs of 1, 2, 3 ... backticks was
quadratic: 321 KB took 7.688s, and release notes are reparsed on every popup
request, so one malformed remote changelog could tie up backend workers across
installed clients. Collect the runs in one pass and walk a cursor per run
length, since a length that runs out of partners stays out. Same 321 KB now
takes 0.013s and 5 MB takes 0.205s. Verified identical output against the old
implementation on 30000 randomized lines.

* Read type 6 and 7 HTML containers in the link resolver too

The resolver masked only type 1 blocks (pre, script, style, textarea), while
the backend parser and the collapsed preview already apply the type 6 and 7
rules, so the three disagreed on the same notes. A <details> or <div> with no
blank line inside is a type 6 block whose contents render verbatim, so two
things went wrong there: a relative link was rewritten into text the reader
sees literally, and a fence inside the block was taken for a real fence, which
silently stopped every link below it from resolving. A blank line, not the
closing tag, ends these blocks, so the common '<div align="center">' followed
by a blank line still holds Markdown and still resolves.

* Mask comments before fences, split only on Markdown line endings, stage the snapshot

Three separate reports, all confirmed against head.

The link resolver tracked no comment state, so a fence delimiter hidden inside
an HTML comment was read as a real fence. The fence then stayed open and every
visible line below was classified as code, so none of its links resolved: one
commented-out draft containing a stray backtick run silently broke the rest of
the notes. Comments are masked now, but only outside a fence, since fenced
content is literal and a comment opener in it is not one. Commented ranges join
the code spans, so a link the reader cannot see is not rewritten either.
Verified with 9 cases under node; 2 fail on the previous file.

str.splitlines also breaks on U+2028, U+2029, NEL, vertical tab and form feed,
none of which end a line in CommonMark. A separator sitting in prose ahead of
"## 9.9.9" made the parser index a release that renders nowhere and truncate
the notes above it: measured, the version list went from 2.0, 9.9.9, 1.0 to
2.0, 1.0 and the 2.0 body stopped being cut at the separator.

The build wrote the snapshot beside the checked-in sources, so a PEP 517 build
against an immutable checkout (Nix, Bazel, a read-only container mount) raised
PermissionError before build_py started and produced no wheel at all. The
source-tree copy is best effort now and the wheel takes its copy from the
staging directory. Reproduced both ways against a read-only package dir.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Use the backend's heading and quote marker rules in the preview

An ATX heading needs an ASCII space or tab after the marker, which is exactly
what _HEADING_PATTERN requires. The \s class also matches a non-breaking space,
so prose beginning "## Important change" with one was classified as a heading
and discarded by collectBullets, and a prose-only release then had no collapsed
preview at all rather than a wrong one.

A blockquote marker takes at most three leading spaces, like every other marker
in this file. Accepting any run let an indented code sample containing
"> - sample output" shed its indentation and enter the collector, so a release
with no real bullets showed code as its summary.

Both reproduced under node against the real module: the two cases fail on the
previous file and pass now, with a real heading, a real quoted bullet and an
ordinary bullet unchanged.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Collect preview reference labels only from lines that can be definitions

A definition-shaped line inside an indented code block or a deep fence is
literal text, so CommonMark leaves a later "[Beta] support" unresolved with its
brackets showing. The pre-scan ran over every line regardless, so the label was
recorded and toPlainText stripped the brackets: the collapsed preview claimed a
resolved reference the expanded notes do not have.

It now skips the same code the collector pass skips. A real definition takes at
most three spaces of indentation, so the indent test cannot reject one, which
the second case checks. Reproduced under node: the indented-code definition
resolved "Beta support" before and keeps its brackets now.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Let a document-level HTML block close an open list item

CommonMark HTML blocks of types 1 to 6 interrupt a paragraph, so a "<div>" to
the left of an open list item closes it and a following one-to-three-space
indented "## 2.0" is a real document heading. Two things stopped that: the block
opener was blanked before the list tracker saw it, so it read as a blank line,
and _may_be_lazy treated it as ordinary text that could continue the item's
paragraph. The item therefore stayed open and the release below the block was
swallowed entirely.

The opener's indentation is now taken before it is hidden, the way a fence
opener's already was, and an HTML block opener is no longer a candidate for lazy
continuation. Type 7 cannot interrupt a paragraph and is deliberately excluded,
since after_paragraph is the only state this helper is asked about.

Measured on the reported shape: the version list went from 3.0, 1.0 to
3.0, 2.0, 1.0. The test also pins the two cases that must not change, an
indented heading genuinely nested in an item and an ordinary lazy continuation,
both of which still suppress the heading.

* Let the download panel shrink inside the capped overlay stack

The bottom-right stack is capped to the viewport, but a flex item defaults to
min-height:auto, so the download panel's outer wrapper could not shrink below
its own content. min-h-0 had been added to the nested panel and not to this
wrapper, so on a short viewport the cap was absorbed by the update card, whose
header and actions are fixed, instead of by the download list, which scrolls.

Only the shared-stack branch takes it. Standalone is positioned fixed and is not
a flex item at all.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten release notes comments

Shorten the comments and docs added with the update popup release notes
so each explains its line in as few words as possible. Comments only, no
behaviour change.

* Measure release-notes indentation from the container

CommonMark measures a block's indentation from its container, not from the
left margin (spec 0.31.2 sections 4.4 and 5.2). The three changelog scanners
measured from the margin in different places, so they disagreed with the
renderer and with each other.

Under "- Details:" the content column is 2, so a four-space line is two
columns in: a paragraph holding a link. The link resolver read it as an
indented code block and left the destination relative, so it resolved against
Studio's own origin instead of the repository.

At document level the same four spaces really are code, and a top-level
bullet is not indented enough to continue the block. The preview promoted an
indented line that looked like a fence opener to a list-contained fence, so
with no later closer every bullet below it was skipped and the collapsed
popup lost its summary.

A fence is scoped to its container too: with no closing line it runs to the
end of the containing block, not the end of the document (section 4.5). A
dedented "## 2.0" closes the list item the fence sits in, so it is a real
release heading. Document-wide fence state kept the block open, so one
missing closing line hid every release below it.

Both frontend scanners now read their list columns from one module ported
from the backend's own tracker, which keeps the three in step.

Two smaller fixes ride along. A release body written as a GFM table rendered
as a grid but previewed as its raw "| Change | Detail | | --- | --- |"
delimiters, so table rows are now dropped from the collapsed summary the way
a code block already is. The comment scanner restarted its code-span search
at the first span for every opener, so a line of N spans and N openers cost N
squared: a 203 KiB line, well inside the 2 MiB the fetcher accepts, took 10.9s
and now takes 41ms.

Differential fuzzing against a CommonMark reference implementation puts the
parser's heading mismatches at 11 of 14275 documents, down from 617, and the
link resolver's at 147 of 6000, down from 217.

* Keep Retry reachable when the release notes fetch fails

The panel took fallbackMarkdown for every response that did not match, error
included, so markdown was always truthy on desktop and the error branch that
carries the Retry button was unreachable. The fallback there is the updater's
static install blurb, not this release's notes, so a transient failure showed
"Download the Apple Silicon .dmg" where the notes should be, with no way to ask
again until the cache expired.

The hook already separates the two: a reported failure is error and retryable,
"no section for this version" is ready and is not. The fallback now applies only
to the second, which is the case its prop documents.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Scope an unclosed comment to its block and end a release on a bare ##

Two CommonMark rules the changelog scanners read too strictly.

An HTML block only opens when the line itself begins with a comment marker
(spec 0.31.2 section 4.6, type 2). One written mid-sentence is inline raw HTML
and, unclosed, is ordinary text. The link resolver carried the open state to
every line below instead, so a note reading "- Type <!-- to begin a comment"
masked the relative links under it and they resolved against Studio's own
origin rather than the repository. maskComments now separates the block form
from the inline one and skips an opener sitting inside a code span, the way
_strip_comments and stripCommentSpans already do. The spans are scanned only
once an opener turns up, so a line without one costs what it did before.

An ATX heading's opening sequence may also be followed by the end of the line
(section 4.2), so a bare ## is an empty level-two heading. Both heading
patterns required whitespace after the hashes, so everything below such a line
stayed inside the release above it and the popup could show unrelated notes
under that version. An empty heading carries no version, so it ends the release
without indexing one of its own.

Differential runs against markdown-it-py: section bodies 7769 to 0 mismatches
over 36069 generated documents, comment-heavy link resolution 705 to 53 over
6000, and previews leaking a bare marker as headline text 22484 to 0 over
40000. The residual link cases are all one shape, a comment block opened inside
a list item that outlives the item, which the fence tracker scopes and the
comment tracker does not, in all three scanners alike.

* Give a hidden comment its own column and balance link destinations

A comment is an HTML block, so one written at the margin under a bullet is not
indented enough to continue that item and closes the list. All three scanners
blanked the line before list tracking saw it, which reads as a blank line and
leaves the item open, so a release heading below it looked like nested item
content and the new release merged into the one above. A hidden line now keeps
its own column through _hidden_structure and hiddenStructure, and only its
column, since the text a comment or a raw block hides is not Markdown and must
not open a list of its own. A line inside a block already open is that block's
content and still keeps nothing.

A link destination may hold parentheses while they balance, so [x]((draft).md)
points at (draft).md. The resolver stopped at the first paren, matched an empty
destination and left the markdown alone, so the link resolved against Studio's
own origin. The balanced form counts only while a closing paren or a title
still ends the link, so the stray paren in [x](a(b.md) stays the closer the way
CommonMark reads it rather than being swallowed into a link across lines.

* Scope paragraph state to the container a line is written in

Two lines the parser read as block starts are lazy paragraph text, so the
list they were written under closed early and the heading indented to the
item's content column was indexed as a release the renderer never shows.

A setext underline may never be a lazy continuation line (spec 0.31.2
section 4.3), so `===` written left of an open item is more of that item's
paragraph. Rejecting every underline-shaped line ended the list there. A row
of three dashes is still a thematic break, which does end it.

Lazy continuation runs the other way too: a marker written outside a
blockquote is not text of the quote's paragraph, so `2. item` under `> quote`
opens a list even though an ordered marker past 1 may not interrupt a
paragraph. Paragraph state is now scoped to its container: a quote line
leaves open only the quote's own paragraph, an underline needs one in its own
container, a definition ends one only when there is none to continue, and a
line four columns past its container is code, which may not interrupt.

The frontend pair reads the same tracker, so both scanners now carry the
quote state and a fence inside a list item ends with the item in the preview
the way it already did on the backend.

Measured against markdown-it-py (CommonMark 0.31.2) over 264k generated
documents: 3368 sections now match the renderer, none regressed, and every
list and quote corpus is exact.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Read a fence and an HTML block from the container it opens in

A block is measured from its container and not from the left margin (spec
0.31.2 sections 4.5 and 5.2), but the link resolver's fence, raw HTML and type
6 expressions all started at the margin, so a fence behind a quote marker and
one three columns under a nested bullet opened nothing. The sample inside was
then read as prose, and a relative link written in a code block or a details
body was rewritten into text the reader is shown verbatim. Matching runs of
backticks hid some of it by accident, since the code span scanner pairs them
across lines, but a tilde fence, a closer of a different length and every HTML
block went through. Each line is now read from the container it is written in,
which the list tracker already knew, and a block is scoped to that container
the way a fence inside an item already was: a line to the left of the item, or
outside the quote, ends the block along with it, and a bare quote marker is
the blank line that ends a type 6 block.

A destination holds parentheses while they balance, and a path may nest them,
so [x](((draft)).md) points at ((draft)).md. One nesting level was all the
expression allowed, so anything deeper fell through to the plain form, matched
an empty destination and left the link resolving against Studio's own origin.
The pairs are unrolled to the 32 levels cmark counts, and the balanced form is
still gated on a closer following it, so the stray paren in [x](a(b.md) stays
the closer the way CommonMark reads it rather than inventing a link across
lines.

Measured against markdown-it-py (CommonMark 0.31.2) over 66k generated
documents, comparing the rendered HTML rather than the destinations alone:
7286 documents in the parenthesis corpus and 313 in the container corpus now
match the renderer, and the link and definition corpora are unchanged. One
container document regresses, where closing the HTML block correctly exposes
an unrelated gap of its own: a link reference definition still leaves a
paragraph open, so the indented line below it reads as prose rather than as
code. The list tracker still matches the backend on every step, the repo's own
CHANGELOG resolves identically, and the pathological inputs measure the same.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Read a block from the item its marker opens, and let a comment reach its paragraph

Four things the three changelog scanners read differently from a renderer.

A fence written straight after a list marker is the item's own first content,
measured from the column that content starts, so "- ```md" opens one. All three
scanners matched the whole line and saw nothing, so the code sample below it was
prose: the resolver rewrote a destination the reader sees verbatim, and the
preview offered the info string as a headline bullet. A shared itemContent /
_item_content reads past a marker that really opens an item, capping the padding
the way the list tracker caps it so an over-indented line is still indented code.
An HTML block opener is read the same way, and its marker survives into the
structural line so the item it opens is still tracked.

An HTML block holds no lazy continuation line, so one opened on an item's
continuation line ends where the item does, exactly as a fence there already
did. The backend and the preview ended it only on a blank line, so it ran past
the item and swallowed the next release heading, which made those notes
unreachable and dropped every bullet below it from the collapsed popup. A raw
block inside an item ends on a blank line too, which is where cmark puts it.

A comment written mid-sentence is inline raw HTML belonging to the paragraph
around it, so its "-->" may arrive on a later line of that same paragraph. Ending
it at its own line left a backtick inside it pairing with a real one below, which
hid a following link from the resolver, and left the preview quoting text the
popup body does not show. A shared commentClosesBelow answers whether the closer
arrives before the paragraph breaks; where it does not, the opener stays the
ordinary text a renderer shows, so a note that merely mentions "<!--" still hides
nothing.

Only ASCII punctuation is escapable, so the backslash in "docs\alpha.md" is a
character of the path. Dropping every backslash rewrote it to a path that does
not exist, and a URL parser reads what survives as a separator, so a Windows or
namespaced path pointed at the wrong file either way. The destination expression
now escapes only punctuation, which also means a space still ends a destination:
"[x](a b.md)" and "[x](a(b.md)" are not links, so their paths are left alone
rather than half-rewritten. A destination that runs out of line still resolves,
since its closer is on the line below.

Fuzzed against markdown-it (CommonMark 0.31.2) over 20k-document corpora, with
the whole rewritten document rendered and compared, not just its destinations.
Release headings: 117 to 16 on containers, 88 to 10 on markers, 17 to 12,
nothing new anywhere. Link destinations: 8823 to 104 on markers, 114 to 98 on
comments, nothing new. Whole-document renders: 9271 to 220, 5116 to 245, 1265 to
671. The Python and TypeScript list trackers still agree over 26861 steps, and
itemContent and hiddenStructure agree over another 6335. 321 KB of unmatched
backticks still measures the same.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Let a definition follow a definition, and read a comment from the item it opens in

Three CommonMark conformance fixes in the changelog scanners.

A link reference definition is a block of its own that may not interrupt a
paragraph, so it opens none either: definitions are allowed to run
consecutively (spec 0.31.2 section 4.7). The link resolver counted one as
paragraph text, so every definition after the first fell outside the set of
lines a definition may start on and kept its relative destination, which then
resolved against Studio's own origin. The backend already read the line this
way.

The guard asking whether a `-->` is reachable from an opener read any line
whose first character was punctuation as the start of a new block. A `-->`
written on a line of its own is how a multiline comment is ordinarily closed,
and a wrapped line may open with emphasis, so neither counted as more of the
paragraph carrying the comment. The comment never closed and the collapsed
popup showed the author's internal note to the reader. It now tests for a
block that may actually interrupt a paragraph.

A comment is an HTML block too (section 4.6, type 2), so one written as a list
item's first content opens inside that item exactly as a fence written there
does. All three scanners looked for the opener at the margin of the line as
written, so a marker in front of it hid the block: the resolver rewrote a
destination inside raw HTML, which Streamdown then shows the reader as a
literal URL, and the preview quoted the hidden note back at them as though the
bullet were Markdown. The opener is now read from the item's content, the
marker survives into the structural line so the item it opens is still
tracked, and the block is scoped to that item the way a fence there is.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Tighten the release notes comments without losing the reasons they record

---------

Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <danielhanchen@gmail.com>
2026-07-28 21:26:43 -07:00