Skip to content

release(larafly): publish 26.09.10 with the admin UX overhaul - #9

Merged
ancongui merged 83 commits into
mainfrom
codex/release-26-09-10
Sep 29, 2026
Merged

ancongui merged 83 commits into
mainfrom
codex/release-26-09-10

Conversation

@ancongui

Copy link
Copy Markdown
Contributor

Release LaraFly 26.09.10 with the completed admin UX overhaul and code-generated OpenAPI corrections. Shared listings now provide stable server-side paging and usable columns; the bean explorer stays bounded while exposing complete navigation; route detail pages explain the compiled contract. Error pages improve recovery, accessibility and problem-document reliability.

OpenAPI now derives path-pattern 404 responses, limits inferred 422 responses to validated typed bodies, and preserves actual JSON/HTML/redirect return contracts. Local Swagger renders nested schemas and responses with improved contrast and mobile controls. The runtime version, README badge, changelog and documentation are synchronized.

Migration notes are included in the changelog: problem instance paths now start with /, the old graph max-nodes setting no longer controls drawing, and listing page sizes must belong to the configured set.

Validation: composer check, composer test:browser, composer test:package, strict Composer validation, component version validation and the tracked-file safety guard. Focused OpenAPI and builtin-viewer checks passed in Firefox and WebKit. CI must also verify PHP 8.3–8.5 and build the site and both book editions before the release tag is published.

Andres Contreras added 30 commits September 23, 2026 13:44
roots() read the LAST `/vendor/` in every frame, so a dependency that
ships its own nested vendor directory contributed a root pointing INSIDE
vendor/ — `<project>/vendor/symplify/monorepo-builder/`. shorten()
deliberately prefers the longest matching root, so that root beat the
real project root and the package's own frames printed as bare
`src/Builder.php`: an application-looking path on a row still dimmed as
vendor, with ErrorFrame::package() answering null. It also broke the one
promise the class makes — the prefix removed was shared by no other row,
so no reader could put the absolute name back.

The root is now taken at the FIRST vendor segment. The markers carry a
leading separator, so strpos cannot be fooled by a directory merely named
`my-vendor`, which is the only reason strrpos was there; shorten()'s
fallback keeps strrpos, where the last vendor segment IS the dependency's
identity.

Covered by a test that builds a real nested-vendor tree, throws through
it, and asserts the derived root is the project root while both frames
keep their `vendor/` prefix.
…s frames shorten

roots() built the base-path root as rtrim($basePath, '/\\').'/' — it accepted
either separator but always appended a forward slash. ErrorPageRenderer feeds it
Application::basePath(), which on Windows is C:\srv\app, while PHP reports trace
files as C:\srv\app\Http\Controllers\OrderController.php, so the root was the
mixed string C:\srv\app/ that no path on that machine can start with. Both of
the first two answers the class advertises — the literal prefix and its
realpath()-normalised twin — were dead by construction there, and the three
lines of str_starts_with that ErrorReport used to carry returned
Http\Controllers\OrderController.php for the same input, so this was behaviour
dropped rather than a case never covered.

It failed invisibly because the third answer, the root derived from a \vendor\
segment, is spelled by the frame itself and survived: every Laravel trace passes
through framework frames, so the dimmed dependency rows shortened while the
application rows — the ones that carry excerpts and are the reason the page
exists — each printed an absolute path and wrapped. That is the 10,108-pixel
page this class was written to end, inverted onto the other platform.

Both spellings are now contributed instead of one being guessed. shorten() skips
any root that does not prefix the file, so the wrong spelling costs a failed
str_starts_with and nothing else, and the two are the same length, so neither
disturbs the longest-first ordering. Pinned by a backslash case — the file had
no backslash path at all, and CI is ubuntu-only — plus an assertion on the
realpath branch, which had the identical flaw.
…row comparator with the data browser

A descending sort on a nullable column opened on a page of em-dashes — the exact failure InMemoryListing's
docblock says it prevents. compare() returned +1 for "left is empty" and order() negated that result together
with the value comparison, so `desc` mirrored the empty-last rule along with the ordering. Emptiness is now
ranked first and returned unnegated, the same way the tiebreak already sits outside the flip, and only the
comparison of two present values is negated.

The comparison itself moves to Firefly\Admin\RowComparator, which DataQueryEngine's unpaged path now shares:
the two in-PHP engines disagreed about '' and about non-scalar cells, so one column header meant two orders on
two pages of one dashboard — the drift DataQueryEngine's own search-filter comment warns about. Its GT/LT
filter keeps comparing without an empty rank, because its paged sibling is `where(col, '>', ?)` in SQL.

The test whose name promised both directions asserted only the ascending one, which is why this shipped green;
it now pins the descending page and the tail where the em-dashes belong, and the data browser gets the same
assertion through the real browser pipeline.
…ged gt/lt filter

Sharing RowComparator with the data browser's unpaged path also changed what `greater than` and `less than`
mean there, which was never in that change's scope. RowComparator renders a value for a READER, so a bool
reaches the comparison as the word `true` or `false`; a word is not numeric, so the pair falls to the natural
text comparison, where `t` and `f` sort after every digit. On a boolean column `greater than 0` therefore
matched EVERY row and `less than 1` none, while the paged sibling `where(col, '>', ?)` bound the bool as 1/0
and answered correctly — one filter in one URL meaning opposite things depending only on whether the
repository could page. That is precisely the drift the docblock on compare() cites as its own justification.

passes() now hands compare() the scalar string it has already computed. `(string) true` is `'1'` and
`(string) false` is `''` — the shapes the driver binds — so every scalar compares exactly as it did before the
comparator was shared, and compare() narrows its parameters to `string` so a raw value cannot return.

The filter had no test over a boolean column, which is why this shipped green; the new case drives gt and lt
over one on the in-PHP resource and over the Eloquent-paged resource, and asserts the two engines agree.
…gin, guard it on every construction path, and document the four keys in the reference
…umn sorts the same every time

RowComparator::compare() chose between arithmetic and natural-text comparison per PAIR, which is not a
strict weak ordering on a column that holds both: with 1.10, 1.9 and 1.9-beta the relation closes a cycle
(1.10 < 1.9 as numbers, 1.9 < 1.9-beta and 1.9-beta < 1.10 as text), and usort() then answers differently
depending on the order the payload arrived in. That is the unstable page InMemoryListing's tiebreak exists
to prevent, reached through the primary comparison where a tiebreak cannot run.

The choice is now made once for the whole column by RowComparator::forColumn(), which both engines that sort
in PHP ask for: InMemoryListing (for the sort column and for the tiebreak, itself a column) and
DataQueryEngine's unpaged fallback. Arithmetic only when every value in the column is a number, empties
aside — they are ranked out before any comparison and are the smallest text under either rule — and
otherwise natural text for every pair. compare() stays the pairwise rule for the gt/lt filter, which holds
one cell against one typed operand and has no column to be consistent with.
…h, instead of dropping the link

The guard refused any value containing a tab, LF or CR ANYWHERE, so a support URL arriving as
"https://support.acme.test\n" became '' — the link silently disappeared, with no exception and no log, for
a character no reader ever sees. The trigger is precisely the mechanism the method's own docblock names as
the source of these values: a Helm block scalar and a here-doc-rendered variable both end in a newline, and
Laravel's Env does not trim a REAL environment variable the way Dotenv trims a .env line. The stated
principle — that a value the browser will re-read differently must be dropped rather than normalised — does
not reach the edges: the URL standard strips every leading and trailing C0 control and space BEFORE it
parses, so boundary padding is re-read as exactly the value the guard would have allowed. The rule is right
for the INTERIOR, which is what makes `java<TAB>script:` an attack, and overshot at the edges, where a
trailing SPACE was already being kept verbatim while the newline spelling of the same padding was fatal.

The edges are now trimmed and every refusal is about the interior. That gives up no ground: each hostile
value trims into another one the guard already refuses (" //evil.test" into "//evil.test", "\0/\evil.test"
into "/\evil.test", "\tjavascript:…" into "javascript:…"). A test asserts both halves together — a
newline-padded https URL is KEPT, "/log<TAB>in" is still refused — so the two can never be conflated again.
…tence and a hard frame budget, and the reference documents both new keys
…ate the page's disclosure contract once

`authoredDetail()` was publishing framework-generated 404 messages as if an author had written them.
`httpMessage()` replaced only the router's "The route … could not be found.", but
Handler::prepareException() rewrites a ModelNotFoundException and a BackedEnumCaseNotFoundException into
`new NotFoundHttpException($e->getMessage(), $e)` BEFORE renderViaCallbacks() reaches this package's
renderable, so a missed route binding — the most ordinary 404 an application has — put "No query results
for model [App\Models\Order] 42" into problem+json's `detail` and onto the report the production page
reads as its lede. The rule is added where the router's already lives, so one branch fixes both surfaces,
and the docblock names each shape's provenance.

ErrorPageSettings stated the disclosure contract two contradictory ways: the class docblock said production
with `trace` off sees the status, reason and code and explicitly "not the message", while the new
`authored-detail` property two screens below defaulted to true and claimed the page prints a sentence. The
"WHAT PRODUCTION SEES" paragraph now carves out the one exception and names its gate, and the property
comment describes what the REPORT carries rather than page behaviour that arrives in a later task.
…bservable

The pager's paged branch — the page window, Previous/Next, the first/last jumps, the gaps and the on/off
classes — never rendered under test. AdminTableCapstoneTestCase seeds seven routes and the smallest size
the rows-per-page control offers is 25, so lastPage() was 1 and isPaged() false in every unit and browser
test; _pager.blade.php is included by one view, so nothing else covered it either. That is precisely the
third of the partial that carries listing state across a page boundary, which is the bug class this wave
exists to remove.

AdminTablePagedCapstoneTestCase gives it a fixture that pages: the skeleton's seven routes plus fourteen
filler ones, and a page size of 2 offered through firefly.admin.table.page-sizes. The size is seeded in
configOverrides() rather than set inside a test because AdminRouteRegistrar builds AdminSettings (and the
TableSettings on it) during the boot passes and binds it as an instance — a config()->set() from a test
body arrives after the object that would have read it. Twenty-one rows at two a page is eleven pages, the
smallest listing on which a five-page window has a gap and a jump on both sides at once.

AdminTablePagerTest asserts the window, the marked current page, the hrefless ends, both elisions and —
the point of the whole partial — that every page link keeps ?q= across the boundary. Four mutations were
checked against it: dropping ListingPage::pageFor(), dropping q from ListingPage::link(), widening
window()'s radius and losing the off class each turn one of the new cases red.

The clamp test was assertion-free: its only assertion was assertSee('Mappings'), which _panel-head prints
on every branch including the empty state, so deleting the clamp left the suite green. It now asserts that
the request renders rows and a pager rather than the empty state, and AdminTablePagerTest asserts which
page it landed on — a one-page listing cannot tell clamping to the last page from clamping to the first.

The three conditional class attributes in the partial became ternaries in the house style, so the markup
they emit is class="act on" rather than class="act  on " and the assertions against it are not pinned to
whitespace Blade happens to leave behind.
…ce stops promising what the page does not print yet

The frame budget trimmed the list and the header went on counting the rows it was
left with, so a hundred-frame stack rendered as "7 of 40 in your code" with no marker
where sixty-four frames had been dropped — a label that was honest before max-frames
existed. It reads the untrimmed totals the report already carries and names them
whenever they differ from what is shown.

Two documents also claimed more than this tree does. firefly.web.error-page.authored-detail
puts the authored sentence on the report and nothing prints it: the reference and the
ErrorPageSettings class comment said the PAGE falls back to a generic sentence when the
key is off, which is what the page does either way. They now say the sentence is carried
(and read today by an application's own error view, which is handed the report), in the
voice the home/sign-in/support block already uses for values that arrive before their
renderer. ProblemMapper::instanceFor() likewise described an RFC 9457 defect as closed
while ProblemDetailsRenderer still publishes the relative form; its docblock says so, and
the method gains the direct test it never had.
…he total label's coverage claim

The three partials left both GET forms unasserted. Deleting the hand-added
hidden `q` from the rows-per-page form, dropping the `@continue` that lets the
<select> own `size`, or removing the hiddenFields() loop from the search form
each passed all 280 admin tests — so an operator who resized a narrowed listing
got the whole listing back, and a search from a sorted page lost the ordering,
with nothing failing either time.

AdminTablePagerTest now asserts the pager form against
`?q=orders&size=2&page=2`: the hidden `q` is there, `page` is not, and
`name="size"` appears exactly once inside the form. AdminTableListingTest
asserts both forms against `?sort=path&dir=desc` and gives its
"says how many rows matched" test the count it never checked (`7 total`
unnarrowed, `5 total` narrowed).

The `_panel-head` docblock claimed tests/Browser/AdminDataBrowserTest.php
pinned its `N total` label, "which hand-rolled exactly this label before it
moved here". Nothing moved: data-list.blade.php is untouched, still hand-rolls
its own <header>, and is what those browser assertions hit. The docblock now
says so, and points at the assertions that do cover this branch — including a
new pager test that tells a grand total from a row count, which a one-page
fixture cannot.
…yout algorithm can resolve

`widths()` emitted `calc((100% - (7.5ch + 1 * 2 * var(--row-x))) * 0.4167)` for a flexible column. That
is valid CSS and is not a width: a `<col>` whose `calc()` mixes a percentage with a SUBTRACTED length is
not resolvable by the fixed-table-layout algorithm, so Chromium falls back to `auto` and every flexible
column collapses to an identical share. Measured on the Routes page: 84.4/453.8/453.8/453.9 on a 1445px
table, where the declared weights 5/4/3 asked for 567/454/340 — the whole weight algebra was inert, and
Tasks 7-10 would have inherited it on all eleven listings.

Flexible columns are now a bare percentage of their weight over the sum of the weights. The subtraction is
unnecessary because the layout engine already performs it: it honours the declared lengths first and
distributes what remains among the percentage columns in proportion to their percentages. Verified in
Chromium for the mixed case, the all-flexible case and two rigid columns plus 60/40.

The browser scenario that was supposed to prove the layout could not have caught this. It asserted only
`table-layout:fixed`, every header wider than 40px and no header wider than 60% of the table — all three
hold on the equal-thirds page that shipped. It now measures the pill column against `7.5ch + 2 * --row-x`
and the other three against 5/4/3 of what is left, within 2px, and reports the pixels it got when it fails.

Also restores `.wrap{overflow-wrap:anywhere}`. The rule had been flipped to `break-word`, but no page in
this wave uses `.wrap` any more — Routes moved to the typed `t-path`/`t-qual`/`t-token` classes — so the
flip only landed on the seven untouched `table-layout:auto` listings, where `anywhere`'s contribution to
min-content sizing is the whole layout. Measured with `break-word`: a 144-character unbreakable value made
the Environment table 258px wider than its panel. The `break-word` semantics stay where they were wanted,
on `table.ftable td.t-text`, where a fixed layout never asks a cell for its min-content size; the
explanatory comment moves there with them. A new browser scenario seeds its own worst case into the
Environment table and asserts the panel still contains it.
…e trace's focus rings to 3:1

A trace row printed the whole call in one shrinkable span, and a call is `Class->method()`: the ellipsis
took the METHOD NAME — the only token that differs across the sixty `Illuminate\…` frames of a Laravel
stack — and kept the namespace every one of them shares. The call is now split the way the path already
was: ErrorFrame gains callQualifier() and callFunction(), cut at the last `->`, `::` or `\` before any
`{` so PHP 8.4's `{closure:/abs/path.php:14}` descriptor survives whole, and the row emits a shrinkable
`.cls` beside a `.fn` that is flex:none. A phone drops the qualifier outright rather than shortening the
method name, because `Builder.php` two columns to its left already said it. row()'s docblock claimed only
`.dir` and `.call` had a discardable end, which was false of both; it now names the end the ellipsis
takes and what that costs.

The two focus rings this trace added were drawn in --brand, which measures 2.86:1 on --panel-2 — below SC
1.4.11's 3:1 floor for a non-text indicator, on the dependency disclosure, the one control a keyboard
reader must operate to reach the frames behind it. Both now use --brand-ink, the token this file already
keeps for the brand as a foreground: 5.35:1 and 5.63:1 in light, unchanged in dark. The contrast test
measured --ink-3 on three grounds and skipped the indicator it shipped in the same commit; it now
measures both, at each one's own threshold, and pins the token as a string.

The browser suite's `assertSee('app/Orders/OrderService.php')` asserted a string the document stopped
containing when the path became two spans; it only matched because Playwright concatenates sibling text
with no separator. It names both halves instead.
…it, and gate the scroll restore behind a documented key

`.tw.free` shipped selecting nothing: no view in the package put `free` on a wrapper, and the test beside
it looked for the rule's text in the stylesheet, which a rule that can never match still satisfies. The set
that wants it is precise — a wrapper around a table with no `<thead>`, where the scrollport pins nothing and
costs a second scrollbar plus a footer link pushed out of sight — and that is the overview's four summary
panels, which now carry it. The stylesheet comment's old justification was wrong twice over: `max-height`
reserves no space, so a four-row health table was never "mostly empty box", and health draws a header and
keeps its scrollport. The assertion is now a computed `max-height` read off the elements themselves.

The scroll restore was ungated, untested and wider than its own comment: it ran on every load of the URL,
so clicking Beans in the sidebar an hour later dropped a reader into the middle of a table with the document
at the top and nothing to explain it. It is now applied only on `reload` and `back_forward` — exactly the
navigations where the browser restored the offset itself before `.tw` took the scrollport from `main` — and
switched by `firefly.admin.table.remember-scroll`, documented in the skeleton reference and defaulting to
on, because it restores parity rather than inventing an affordance. Saving moved from `beforeunload` to
`pagehide`, which does not make the page ineligible for the back/forward cache. Both halves are measured in
Chromium: the reload brings the offset back, the plain navigation opens at the top.
…d of clipping it at the panel edge

`.frames .call .fn` was `flex:none` on the theory that a span which cannot shrink keeps its text. It keeps
its WIDTH: its box stays as wide as its glyphs, its own `text-overflow` therefore never has a narrower box
to draw an ellipsis in, and the glyphs run out of the shrunken row to be cut by `.panel{overflow:hidden}`
with nothing marking the cut. Measured in Chrome over a 35-frame Laravel trace, 34 of 35 rows at 375px
painted their method name up to 138px past the panel — `->whereHasMorphRelationship` arriving as `->wh` —
and rows still overflowed at panel widths up to 800px.

The ellipsis order is now stated with shrink factors instead of a refusal to shrink: `.dir` and `.cls`
shrink at 100 and `.fn` at 1, so flexbox spends both discardable spans to nothing before taking a character
off the method name, and `overflow:hidden` on `.call` keeps whatever is taken inside the row. At every
panel width from 540px up that leaves 35 of 35 rows on one line with nothing past the panel.

A phone cannot be answered that way: a row has 295px there and `AddQueuedCookiesToResponse.php` alone is
226 of them, so ranked shrinking ends with 23 of 35 method names cut and two rendered at zero width. Below
560px the row wraps and the call takes a line of its own, the directory joins `.pkg` and `.cls` in being
dropped — it was already ellipsised to `vendor/la…` on every row — and the call keeps the same 24em guard
it has everywhere rather than the old 14em one it no longer needs. Two lines a frame, 1,961px of trace
where the unwrapped page measured 1,407 and the pre-wave one 17,465; nothing wraps inside a span.

Also pins the dependency disclosure's honesty, which was entirely unasserted: its label counts the
UNTRIMMED vendor set and the `.dn` note says how many of those the budget actually left, and both are now
asserted from the report's own counts, together with the note's absence when nothing was trimmed.
…s durations by their leading digit

The Fixed rate and Fixed delay columns held Cadence duration STRINGS while carrying TableColumn::number()'s default sortable: true, so RowComparator::forColumn() committed them to strnatcasecmp and ascending by rate answered 1h, 5m, 30s, 250ms. Both columns are now sortable: false, like meter() and actions(), and the trigger() docblock says why a duration string cannot be ordered here instead of clearing the ordering it offered. The scheduled test asserted the column TYPING only, and class="t-num" matched the <th> rather than the t-num dim cells, so rewriting trigger() around is_numeric() would have em-dashed every trigger on the page and stayed green; it now asserts the normalised cells the fixture was built to produce and that neither interval header links.
…of your own

A stack whose every frame is under `vendor/` rendered the trace panel as a
heading over a closed disclosure with not one frame visible: `frames()` emits
`<ol class="frames">` only when there are application frames, and
`dependencies()` hardcoded a closed `<details>`. `ErrorFrame::$vendor` is
decided by `/vendor/` in the path alone, so this is the shape every failure
raised before application code runs takes under a worker deployment — Octane,
FrankenPHP worker mode, Vapor — whose front controller is itself a dependency
and so never puts an application frame on the floor of the stack. The
disclosure's open state is now the caller's decision and is open when it is the
only thing in the panel.

The `.deps` border-top is dropped when it follows the panel heading directly,
which is exactly that case: the heading already draws a border-bottom and the
two adjacent rules painted as one 2px line.

Also anchors the browser assertion on the domain 404's path spans at
`app/Orders/</span>` rather than from the project root. The harness serves the
skeleton from `<monorepo>/skeleton/app/…` and SourcePaths derives its root from
the outermost `vendor/` segment, so the page prints `skeleton/app/Orders/`
there and `app/Orders/` in a created project; the assertion pinned the created
project's layout and failed in the monorepo.
…e says it searches, and pin the conditions page links over a fixture that pages

beanRows() ran every interface through Format::leafOf() BEFORE the row was built, so the `interfaces` cell
— and with it the `?q=` index over that column — held only leaf names. The Beans page promises the opposite
in two strings it renders verbatim: "Search by class, stereotype or interface…" in the placeholder and
"class, stereotype, scope, name or interfaces" in the empty state. Pasting the name an operator actually
has to hand answered a confident "Nothing matches": q=HealthIndicator found three beans and
q=Firefly\Actuator\Health\HealthIndicator found none, while those same three implement exactly it; ten
beans against Firefly\Actuator\Endpoint\ActuatorEndpoint, same story. It was also the one column on the
page that did not follow the page's own arrangement — `class` keeps the FQCN in the row and shortens it in
the view — and the truncation emptied the cell's title of its purpose, a cell reading ActuatorEndpoint
carrying title="ActuatorEndpoint".

The row now carries both: `interfaces` holds the leaves, which is what the column draws and therefore what
it may be ordered by, and `interfacesQualified` holds the FQCNs, which is what `?q=` also looks inside and
what the cell hovers. That keeps InMemoryListing's rule intact — a searched value has to be visible to the
reader, and this one is visible exactly where the Class column's FQCN already was — without giving the
Implements column an ordering by a namespace nobody can see.

The conditions test named for paging never rendered a paged panel. Nine conditions apply in an
auto-configured testbench and this harness backs two off, against a default page size of 50, so
ListingPage::isPaged() was false on both panels and _pager's paged branch — the only caller of
ListingPage::link() anywhere — never rendered: the pager read "1–9 of 9" and then went straight to the
spacer. conditionsPage() rebuilds each slice on the CARRYING query precisely so those links keep the
sibling's position, and deleting both rebuilds left all 296 tests green; what the old assertions caught was
the carrying through the two GET forms, which is a different mechanism. AdminTableConditionsPagedCapstoneTestCase
offers a page size of 2 through configOverrides() (AdminSettings is built during the boot passes, so a
config()->set() from a test body arrives too late) and grows the backed-off side to five rows, and the new
suite pins the hrefs both pagers draw — including a sibling `neg_q` that a page link would otherwise drop,
widening a panel the reader never touched. Both rebuilds now fail the suite when removed. The older test
keeps its assertions under a name that says what they cover.
Andres Contreras added 29 commits September 23, 2026 21:36
…derives, emit its colgroup from TableView, and say what its page-size cap really does

Three findings from the review of Task 10, all of them about the listing
this wave moved onto the shared table system.

The rigid <col> widths were chosen from the column TYPE alone. Every
other .ftable listing writes its own labels beside hand-tuned widths, but
this page humanises a database column name — DataColumn::label() turns
`failed_login_attempts` into `Failed login attempts` — so nobody ever saw
the label the width had to hold. Measured in Chromium against these
rules, that header renders 184px inside a 126px box and reads
`FAILED LOGIN ATTE`: `thead th` is nowrap, `table.ftable td,th` is
overflow:hidden with no ellipsis, and under table-layout:fixed the column
cannot grow the way the table-layout:auto this wave replaced did. A rigid
column is now widened to the greater of its type's alphabet and what its
own header needs, through TableColumn::fittingItsHeader() — 1.25ch per
character plus 1.75 for the ordering indicator, both measured and both
documented beside the numbers TableColumn already carries — and the <th>
puts its full label on `title`, because a character count cannot measure
a font.

The colgroup was a second mechanism beside the one packages/admin/src/Table
already owns: literal `calc(19ch + 2 * var(--row-x))` strings in a Blade
@php, with the 19 copied out of TableColumn::stamp()'s default under a
comment claiming the two agreed that nothing enforced. AdminAction::
dataTableView() maps each DataColumn onto the TableColumn kind that knows
its own width and the view emits TableView::widths(), so the 19 is that
default rather than a copy of it and the text columns are the resolvable
percentages the fixed-layout algorithm wants instead of `auto`. The
`t-<dbtype>` classes stay on the cells — they are the database's types,
which ColumnKind does not model — and the header row gains the
`scope="col"` the shared _table-head partial emits. The dead `width:1%`
rules the colgroup replaced are gone with their stale explanation.

And the documented reference for firefly.admin.data.max-page-size still
described a cap. TableSettings::clamp() is a closed set: on the listing
`?size=300` is refused and falls back to 25 rather than being lowered to
200, and so is `?size=10`, which this page's own removed <select> offered
until this wave. The table row, the paragraph under it that contradicted
it, the skeleton's comment and a changelog entry now all say that, and
the stale `?perPage=1000000` is `?size=`.
…nt up to the RFC 9457 shape

ErrorPageSettings::$typeUri was the one configuration-supplied URI in the class that reached
its consumer verbatim: Config::string() does not trim, ProblemType::of() compares the base to
its 'about:blank' and '' sentinels with ===, and the value is published in the member RFC 9457
§3.1.1 defines as dereferenceable and every API console renders as a link. A base of
" about:blank" published " about:blank/resource-not-found", a Helm block scalar's trailing
newline published a type with a newline inside it, and "javascript:alert(document.cookie)"
reached the wire intact. It is now assigned through self::typeUri() in the constructor, beside
the three self::url() assignments, so the guarantee holds for a settings object built by hand
as well as one read from configuration: the edges are trimmed, an interior tab/LF/CR is
refused, and only '', 'about:blank' or an absolute http(s) base is kept — anything else falls
back to the documented default rather than to '', which is a position an operator takes
deliberately.

The RFC 9457 conformance pass also left the documentation stating the behaviour it inverted.
Both book editions taught the old, un-slashed `instance` as a deliberate lesson one paragraph
below the listing the pass had just edited; ten published samples across the book and the
tutorial showed a document the framework can no longer produce, none of them carrying the
`type` member that is now present by default. Both paragraphs are rewritten around §3.1.5, and
every sample gains its leading slash and its type. docs/modules/error-handling.md is left
alone on purpose: its JSON is the output of the ErrorResponse::fromException() listing printed
above it, and fromException() sets no type.

A new guard in tests/DocsProseIsRealTest.php derives the reference from
ProblemMapper::instanceFor() and the member order from a real ErrorResponse::toArray(), then
holds every "instance" in every fenced block on every prose page to both, and refuses a
paragraph that explains the member as $request->path() without naming what replaced it.
ProblemMapper::instanceFor()'s docblock no longer claims the published document is unfixed.
… origin

Adding a leading slash to `$request->path()` turned a same-origin reference
into a cross-origin one. `path()` does not strip a backslash from a real
request — Symfony refuses one only inside `Request::create()` — so a
REQUEST_URI of `/\evil.test/phish` answers `\evil.test/phish`, which resolves
against this origin, while the prefixed `/\evil.test/phish` enters the URL
parser's special-authority-ignore-slashes state and resolves to
https://evil.test/phish. ASCII tab, LF and CR are the same hazard by the rule
that deletes them before parsing. ProblemMapper::instanceFor() now
percent-encodes all four, which RFC 3986 admits in no path segment anyway, so
the member still identifies the occurrence and ErrorReport's copy is guarded by
the same line.

ErrorResponse::fromException() grows an optional `type`, and the renderer's
thirteen-argument re-declaration of the DTO is gone: that second construction
site had a default for every member, so a member added to ErrorResponse later
would have been dropped from every published document in silence.

The published contract and the prose catch up with both: ProblemSchema
described `instance` as a request path and `type` as a member emitted only when
passed, and both halves were false; the book's OpenAPI chapter said the same in
two languages; the testing chapter taught a toBeProblemDetails() limitation this
wave removed, and samples/lumen now exercises the expectation it used to warn
against; docs/modules/error-handling.md documents firefly.web.problem.type-uri
and the members only the renderer supplies. The prose guard is widened to judge
a paragraph that calls `instance` a request path in either language, which is
the sentence that outlived the conformance pass.
…ure the phone width on the listing this wave is about
The task-10 draft expected production ledes under trace=true and omitted Accept through a harness that supplies HTML. Bind those assertions to the production case and explicitly remove the injected header; shipped behavior takes precedence over the stale plan.
Retain the newer mobile call-boundary and clipboard rejection assertions absent from the old task-11 snippets. Fresh captures bound desktop and phone layouts and the wildcard request exercises the real pipeline.
The live tree already documents all nine configuration keys, unlike the stale task-12 insertion steps. Preserve those entries, describe encoding degradation and mobile wrapping as implemented, and explicitly reject unreadable screenshots before measuring them.
The dossier assumed underscore-only codes, but FireflyException accepts arbitrary strings. Percent-encode the derived segment and use about:blank for dot segments so custom codes cannot introduce query, fragment or parent-path semantics.
The code has a remember-scroll setting beyond the five named in the task-12 draft, and already documents data-browser size composition. Preserve that newer contract and describe reload-only scroll restoration.
Retain the consolidated package documentation guards and add the error response provenance checks. Keep the error changes unreleased above the existing 26.09.5 through 26.09.9 history. The conflict predicted by the dossier also includes the newly completed changelog.
The dossier treated route paths, condition classes and correlation IDs as unique; current endpoint data permits duplicates. Use composite identities and exact text fallbacks so rebuilt listings retain deterministic page boundaries, including descending identifier sorts and the data browser fallback.
Keep both waves unreleased above the current release history. Unlike the original dossier baseline, main publishes the root package; carry illuminate/auth from the error branch component requirement into that root manifest. Full composer check exits 0 with 4104 tests passing and zero Deptrac violations; browser suite exits 0 with 103 tests passing.
Replace recursive levels with iterative SCC condensation, preserve self cycles, and allocate hop columns round robin. The approved design supersedes the old self-edge suppression test: self injection now has explicit cycle coverage. Pre-count factories globally; ambiguous products no longer pretend the first candidate is the container winner. Existing producer projection does not provide scope, so report it as unknown.
Read resolver supports without resolving arguments, preserve signature positions and identify shadowed registrations. Derive possible binding failures from ArgumentResolver, correcting the dossier: required body and service bindings do not imply missing parameters, uploads can fail conversion, and only loadable typed bodies hydrate. Read public route metadata with normalized paths and advice binding state without instantiating interceptors.
Compute overflow after both directions finish placement so a cyclic neighbor drawn on the opposite side is not reported missing. Add the regression and all eight configuration references. Model and configuration tests pass; PHPStan max passes including the in-progress page work.
Assert the displayed validation code against the real ValidationException errorCode and preserve the complete signature order for the dispatch explanation. Pattern sentences use the PHP argument name rather than the wire key.
Keep query state across normal links and gate detail, advice and destination pages. Render compiled bindings, effective pattern failures, bounded DTO disclosures, sibling comparisons, duplicate registrations, response handlers and advice provenance. Keep recent traffic and metrics as honest application-wide links; read normalized Laravel metadata without synthetic requests. Document new switches in the skeleton and correct the existing Admin to Web dependency comment without changing edges.
Exercise real JavaScript-disabled keyboard navigation, focus, phone table containment and light/dark color contrast. Preserve the original one-line route path geometry assertion by measuring visible path text after links gain hidden verb text. Cover resolver claims, endpoint and page gates, all advice binding states, and documentation for route switches and inspection limits.
Follow qualified bound and unbound configuration filters and verify the linked page actually narrows. Describe html as controller declaration metadata rather than a media-type promise, with ResponseFactory regressions for both stereotypes. Preserve encoded zero defaults, recover clipped binding values and verify the scrolled phone contract on the same browser page.
Replace the whole-graph camera with server-rendered focus columns, native links, complete typed catalogues, module ports and bounded relation, cycle and root-path listings. Preserve factory identities, distinct contracts, honest ambiguity and distinct-neighbour degrees. Document every tunable and the legacy graphMaxNodes migration in both books.

The historical dossier predates the shared typed table and understates the live hover half of the global binder; reuse the current table implementation and remove both old graph bindings. Replace old self-edge suppression with cycle coverage and pair-only deduplication with distinct-contract coverage. Existing table assertions now include accessible sort and paging attributes without reducing behavior coverage.

Validation: composer check exited 0: 4132 passed, 6 skipped, 16766 assertions; Pint, PHPStan max and Deptrac passed. Chromium browser suite exited 0: 110 passed, 965 assertions. Focus, dark, mobile drawing, dense relations and module screenshots inspected.
Exercise true no-JavaScript search and anchor activation, visible keyboard focus, mobile fixed geometry and aligned headings, both-theme text contrast, exact dense overflow, and module ports. Safari uses its native macOS Option-Tab link navigation. Update the dashboard heading assertion for the approved explorer replacement.

Validation: full Chromium suite exited 0 with 110 tests and 984 assertions; focused Firefox and Safari each exited 0 with 7 tests and 30 assertions. Pint and PHPStan max passed after the final test edits. Inspected light, dark, scrolled phone, dense relation and module screenshots; generated evidence remains ignored.
Runtime dispatch validates typed bodies, rejects path patterns with 404, and selects response media from returned values. Correct the generator and older documentation assumptions, retaining authored response overrides.

Verify the compiled skeleton operations, tags, nested schemas and response statuses in Swagger. Improve contrast and WebKit phone controls without changing vendor assets. Full composer check and browser gates pass; no dependency or build changes.
@ancongui
ancongui merged commit 61ce8d1 into main Sep 29, 2026
7 checks passed
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