Draw the console rail's current item's ring whole, as Rastrillo does
A nav that scrolls clips at its padding box, so the wide console rail cut its current item's ring and contact shadow off at its edges. The nav now has 5px of padding, given back by negative margins, a smaller sticky inset and a larger cap, so no item moves (Rastrillo cc7c9c69). The fix changes 1.0 declarations, so the gates name it rather than bend: refinedDeclarations lists each changed and added declaration, value for value, for the two declaration gates, with cases proving a near value or the same value outside the media query is refused. The parity and 1.0 photographs get one rendering exception, consoleRailRing: the current item's box grown by 5px is blanked on both sides, must sit in the same place on both, and must cover a real change in Day. TestTheConsoleRingExceptionLetsNoLayoutThrough is its negative control: without the negative margins the items move and the comparison fails. The frozen 1.0 tree is untouched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+350
−34
check/baseline_test.go+104 −2check/matrix_browser_test.go+15 −2check/oneoh_browser_test.go+33 −15check/parity_browser_test.go+183 −5css/tokens.css+7 −2releases/1.2.0/SHA256SUMS+4 −4releases/1.2.0/fiesta-plain.css+1 −1releases/1.2.0/fiesta-signal.css+1 −1releases/1.2.0/fiesta.css+1 −1releases/1.2.0/tokens.css+1 −1
diff --git a/check/baseline_test.go b/check/baseline_test.go| index 4ac8509..72041a5 100644 |
| --- a/check/baseline_test.go |
| +++ b/check/baseline_test.go |
| @@ -162,6 +162,80 @@ var newOnOldMarkup = map[string]string{ |
| // a tab and keeps the look 1.0 gave it. |
| const segTabButton = "1.0 documents only links in a seg-tabs track and leaves a button in one unstyled" |
| +// refinement is one declaration under a 1.0 selector that a 1.x release |
| +// changes or adds on purpose, to fix how 1.0 markup draws: its file, |
| +// context, selector and property, the value 1.0.0 gave it (Was, empty |
| +// for an addition), the value the tree gives it (Now), and why. The /1/ |
| +// promise allows a fix the changelog announces; this list is where each |
| +// is named, value for value, so nothing else passes for one. Every entry |
| +// must be used: a 1.0 declaration with Was changed to exactly Now, or a |
| +// new record that is exactly Now. |
| +type refinement struct { |
| + File, Context, Selector, Property, Was, Now string |
| + Why string |
| +} |
| + |
| +// The console rail's nav, in both spellings, under the rule that puts |
| +// the rail beside the page. |
| +const ( |
| + railWide = "@media (min-width: 800px)" |
| + railNav = ".rst-shell-console > .rst-shell__rail > .rst-shell__nav" |
| + railNavAttrs = "[rst-shell-console] > [rst-shell-rail] > [rst-shell-nav]" |
| +) |
| + |
| +// railRing is why each of the console rail's refinements is there. |
| +const railRing = "1.2.0 takes Rastrillo cc7c9c69: a nav that scrolls clips " + |
| + "at its padding box, so 1.0's console rail cut its current item's " + |
| + "ring and contact shadow off at its edges. 5px of padding gives " + |
| + "them room, and the negative margins, the smaller sticky inset and " + |
| + "the larger cap give it back, so no item moves (consoleRailRing in " + |
| + "parity_browser_test.go photographs it)" |
| + |
| +// refinedDeclarations is every refinement 1.x makes, in file order. |
| +var refinedDeclarations = func() []refinement { |
| + var out []refinement |
| + for _, nav := range []string{railNav, railNavAttrs} { |
| + for _, d := range [][3]string{ |
| + {"inset-block-start", "var(--rst-sp-4)", "calc(var(--rst-sp-4) - 5px)"}, |
| + {"margin-block", "", "-5px"}, |
| + {"margin-inline", "", "-5px"}, |
| + {"max-block-size", "calc(100dvh - 2 * var(--rst-sp-4))", "calc(100dvh - 2 * var(--rst-sp-4) + 10px)"}, |
| + {"padding-block", "", "5px"}, |
| + {"padding-inline", "", "5px"}, |
| + } { |
| + out = append(out, refinement{File: "css/tokens.css", Context: railWide, Selector: nav, Property: d[0], Was: d[1], Now: d[2], Why: railRing}) |
| + } |
| + } |
| + return out |
| +}() |
| + |
| +// refined reports which refinement turns was into is, if one does: the |
| +// same file, context, selector and property, neither !important, was's |
| +// value the entry's Was and is's its Now. |
| +func refined(was, is declaration) (int, bool) { |
| + for i, r := range refinedDeclarations { |
| + if r.Was != "" && was.File == "check/testdata/seed/"+baseline[strings.TrimPrefix(r.File, "css/")] && |
| + is.File == r.File && was.Context == r.Context && is.Context == r.Context && |
| + was.Selector == r.Selector && is.Selector == r.Selector && |
| + was.Property == r.Property && is.Property == r.Property && |
| + was.Value == r.Was && is.Value == r.Now && !was.Important && !is.Important { |
| + return i, true |
| + } |
| + } |
| + return 0, false |
| +} |
| + |
| +// addedByRefinement reports which refinement adds d, if one does. |
| +func addedByRefinement(d declaration) (int, bool) { |
| + for i, r := range refinedDeclarations { |
| + if r.Was == "" && d.File == r.File && d.Context == r.Context && d.Selector == r.Selector && |
| + d.Property == r.Property && d.Value == r.Now && !d.Important { |
| + return i, true |
| + } |
| + } |
| + return 0, false |
| +} |
| + |
| // declaration is one thing a stylesheet says, read in the context that |
| // decides when it applies: the chain of grouping rules around it, one |
| // selector of its rule's list, the property, the value and whether it |
| @@ -514,6 +588,9 @@ func sortedKeys[V any](m map[string]V) []string { |
| // property, value and weight, under the same selector or under the |
| // selector a planned widening turned it into. |
| func carries(was, is declaration) bool { |
| + if _, ok := refined(was, is); ok { |
| + return true |
| + } |
| if was.Context != is.Context || was.Property != is.Property || was.Value != is.Value || was.Important != is.Important { |
| return false |
| } |
| @@ -537,7 +614,7 @@ func carries(was, is declaration) bool { |
| // every file under css/ has a baseline twin, or a stylesheet added |
| // beside the five would ship held to nothing. |
| func TestEveryOneOhDeclarationStillApplies(t *testing.T) { |
| - used := map[string]bool{} |
| + used, refinedUsed := map[string]bool{}, map[int]bool{} |
| for _, f := range everyStylesheet(t) { |
| t.Logf("css/%s: 1.0.0 makes %d declarations, the tree %d", f.src, len(f.was), len(f.is)) |
| next := 0 |
| @@ -555,6 +632,9 @@ func TestEveryOneOhDeclarationStillApplies(t *testing.T) { |
| if was.Selector != f.is[found].Selector { |
| used[was.Selector] = true |
| } |
| + if i, ok := refined(was, f.is[found]); ok { |
| + refinedUsed[i] = true |
| + } |
| continue |
| } |
| moved := false |
| @@ -589,6 +669,12 @@ func TestEveryOneOhDeclarationStillApplies(t *testing.T) { |
| } |
| } |
| + for i, r := range refinedDeclarations { |
| + if r.Was != "" && !refinedUsed[i] { |
| + t.Errorf("refinedDeclarations: no 1.0 declaration %s { %s { %s: %s } } became %s in %s; a refinement of nothing is a hole waiting to be used", r.Context, r.Selector, r.Property, r.Was, r.Now, r.File) |
| + } |
| + } |
| + |
| // Every file under css/ is one of the five. |
| root := filepath.Join("..", "css") |
| err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { |
| @@ -762,7 +848,7 @@ func refusedAdditions(t *testing.T, files []sideBySide) []string { |
| } |
| exceptions[sel] = parsed |
| } |
| - used := map[string]bool{} |
| + used, added := map[string]bool{}, map[int]bool{} |
| for _, f := range files { |
| taken := make([]bool, len(f.is)) |
| matched := make([]bool, len(f.was)) |
| @@ -790,6 +876,10 @@ func refusedAdditions(t *testing.T, files []sideBySide) []string { |
| if taken[j] { |
| continue |
| } |
| + if i, ok := addedByRefinement(d); ok { |
| + added[i] = true |
| + continue |
| + } |
| if why := newRecordRefused(d, names, oldProps, oldAtRules, exceptions, used); why != "" { |
| refused = append(refused, fmt.Sprintf("css/%s: %s is new and %s", f.src, d, why)) |
| } |
| @@ -800,6 +890,11 @@ func refusedAdditions(t *testing.T, files []sideBySide) []string { |
| refused = append(refused, fmt.Sprintf("newOnOldMarkup[%q] is used by no new declaration; an exception for nothing is a hole waiting to be used", sel)) |
| } |
| } |
| + for i, r := range refinedDeclarations { |
| + if r.Was == "" && !added[i] { |
| + refused = append(refused, fmt.Sprintf("refinedDeclarations: nothing adds %s { %s { %s: %s } } to %s; an exception for nothing is a hole waiting to be used", r.Context, r.Selector, r.Property, r.Now, r.File)) |
| + } |
| + } |
| return refused |
| } |
| @@ -861,6 +956,13 @@ func TestTheNewRuleGateHasNoKnownHoles(t *testing.T) { |
| // 1.0 page, so the reading that refuses the rules above must |
| // still let it through. |
| {`[rst-snippet]::before { content: "/* */"; }`, "[rst-snippet]::before", false}, |
| + // A refinement is its value and nothing near it: the console |
| + // rail's 5px of padding passes, 6px under the same selector does |
| + // not, and neither does 5px under the same selector outside the |
| + // media query that puts the rail beside the page. |
| + {`@media (min-width: 800px) { [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { padding-block: 5px; } }`, "[rst-shell-nav] { padding-block: 5px } } is new", false}, |
| + {`@media (min-width: 800px) { [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { padding-block: 6px; } }`, "[rst-shell-nav] { padding-block: 6px } } is new", true}, |
| + {`[rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { padding-block: 5px; }`, "css/tokens.css: [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { padding-block: 5px } is new", true}, |
| } { |
| // A stylesheet the reader refuses fails the gate before it |
| // compares anything, so the refusal is the reader's error. |
diff --git a/check/matrix_browser_test.go b/check/matrix_browser_test.go| index d1bcab9..80b5cca 100644 |
| --- a/check/matrix_browser_test.go |
| +++ b/check/matrix_browser_test.go |
| @@ -276,10 +276,23 @@ func matrixKey(scheme string, states []string, selector string) string { |
| // pairs, and the pixels are the same pixels. The crops are compared one |
| // by one so the 1% allowance is a component's, not the page's. |
| func matrixShots(t *testing.T, ctx context.Context, origin, dir, scheme string) map[string][]byte { |
| + t.Helper() |
| + shots, _ := matrixShotsExcept(t, ctx, origin, dir, scheme, nil) |
| + return shots |
| +} |
| + |
| +// matrixShotsExcept is matrixShots with the areas an exception covers |
| +// blanked in every shot before it is cropped, and those areas returned, |
| +// so the caller can hold them to the same place on both sides. |
| +func matrixShotsExcept(t *testing.T, ctx context.Context, origin, dir, scheme string, exception *renderingException) (map[string][]byte, []image.Rectangle) { |
| t.Helper() |
| mustRun(t, ctx, chromedp.EmulateViewport(1280, 1600), chromedp.Navigate(origin+dir), chromedp.WaitVisible("#field", chromedp.ByQuery), |
| chromedp.Evaluate(`document.documentElement.setAttribute("data-theme", "`+scheme+`"), document.getElementById("dd").open = true, true`, nil)) |
| still(t, ctx) |
| + var excepted []image.Rectangle |
| + if exception != nil { |
| + excepted = exception.areas(t, ctx) |
| + } |
| out := map[string][]byte{} |
| for _, states := range matrixStates { |
| var pinned []string |
| @@ -306,7 +319,7 @@ func matrixShots(t *testing.T, ctx context.Context, origin, dir, scheme string) |
| } |
| var buf []byte |
| mustRun(t, ctx, chromedp.FullScreenshot(&buf, 100)) |
| - img := decodePNG(t, buf) |
| + img := blanked(decodePNG(t, buf), excepted) |
| for _, b := range boxes { |
| out[b.key] = cropPNG(t, img, b.x, b.y, b.w, b.h, matrixMargin) |
| } |
| @@ -314,7 +327,7 @@ func matrixShots(t *testing.T, ctx context.Context, origin, dir, scheme string) |
| forceState(t, ctx, force) |
| } |
| } |
| - return out |
| + return out, excepted |
| } |
| // TestEveryThemeRendersAsItDidExceptDay is a preservation test: it does |
diff --git a/check/oneoh_browser_test.go b/check/oneoh_browser_test.go| index a0070a6..a874fe9 100644 |
| --- a/check/oneoh_browser_test.go |
| +++ b/check/oneoh_browser_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| + "image" |
| "io/fs" |
| "net/http" |
| "os" |
| @@ -226,11 +227,11 @@ const imagesLoadedJS = `new Promise(r => { |
| // word of where it stopped (which is how a fleet run once read). |
| const oneOhShotBudget = 60 * time.Second |
| -// oneOhShot photographs one side's copy of a document in one leg. |
| -// Before the photograph every picture has loaded and the rig has seen |
| -// no failed request or response of 400 or more, or the test fails |
| -// naming them. |
| -func oneOhShot(t *testing.T, rig *harness.Rig, ctx context.Context, side, doc string, leg oneOhLeg) []byte { |
| +// oneOhShot photographs one side's copy of a document in one leg, and |
| +// reads the areas consoleRailRing covers on it. Before the photograph |
| +// every picture has loaded and the rig has seen no failed request or |
| +// response of 400 or more, or the test fails naming them. |
| +func oneOhShot(t *testing.T, rig *harness.Rig, ctx context.Context, side, doc string, leg oneOhLeg) ([]byte, []image.Rectangle) { |
| t.Helper() |
| url := rig.Origin + "/" + side + "/examples/" + doc |
| if leg.rtl { |
| @@ -246,30 +247,38 @@ func oneOhShot(t *testing.T, rig *harness.Rig, ctx context.Context, side, doc st |
| t.Errorf("%s: no answer within %s; the page or the browser hung", where, oneOhShotBudget) |
| } |
| }() |
| - return pageShotChecked(t, ctx, url, leg.width, leg.scheme, func() { |
| + var areas []image.Rectangle |
| + shot := pageShotChecked(t, ctx, url, leg.width, leg.scheme, func() { |
| var broken []string |
| mustRun(t, ctx, chromedp.Evaluate(imagesLoadedJS, &broken, awaitPromise)) |
| if len(broken) > 0 { |
| t.Fatalf("%s: %d pictures did not load: %s", where, len(broken), strings.Join(broken, ", ")) |
| } |
| rig.Screen("body", where) |
| + areas = consoleRailRing.areas(t, ctx) |
| }) |
| + return shot, areas |
| } |
| // sameOneOh photographs doc on both sides in each leg and fails each |
| -// leg whose pictures differ by a single pixel. One pair is enough: |
| -// the rig rasters on one thread in software (WithDeterministicRaster), |
| -// under which 400 pairs of one page agreed under load; without it one |
| -// state's callout corner came out one level apart in 4 of them. |
| -func sameOneOh(t *testing.T, rig *harness.Rig, ctx context.Context, doc string, legs []oneOhLeg) { |
| +// leg whose pictures differ by a single pixel outside consoleRailRing's |
| +// areas, or whose areas moved. One pair is enough: the rig rasters on |
| +// one thread in software (WithDeterministicRaster), under which 400 |
| +// pairs of one page agreed under load; without it one state's callout |
| +// corner came out one level apart in 4 of them. It reports whether the |
| +// exception covered a change in any leg. |
| +func sameOneOh(t *testing.T, rig *harness.Rig, ctx context.Context, doc string, legs []oneOhLeg) (covered bool) { |
| t.Helper() |
| for _, leg := range legs { |
| - was := oneOhShot(t, rig, ctx, "old", doc, leg) |
| - is := oneOhShot(t, rig, ctx, "new", doc, leg) |
| - if ok, why := samePixelsWithin(t, was, is, 0); !ok { |
| + was, wasAreas := oneOhShot(t, rig, ctx, "old", doc, leg) |
| + is, isAreas := oneOhShot(t, rig, ctx, "new", doc, leg) |
| + ok, why, changed := samePixelsExcept(t, was, is, wasAreas, isAreas) |
| + if !ok { |
| t.Errorf("1.0.0's %s at %s renders differently under the tree's CSS: %s", strings.TrimSuffix(doc, ".html"), leg, why) |
| } |
| + covered = covered || changed |
| } |
| + return covered |
| } |
| // oneOhLayouts is 1.0.0's seven layout documents, by name, read off the |
| @@ -320,8 +329,17 @@ func oneOhLayoutsRender(t *testing.T, k int) { |
| theme, scheme := themes[k/2], []string{"light", "dark"}[k%2] |
| layouts := oneOhLayouts(t) |
| rig, ctx := oneOhRig(t, theme, nil) |
| + covered := false |
| for _, layout := range layouts { |
| - sameOneOh(t, rig, ctx, layout+"/default.html", oneOhLayoutLegs(scheme, "screen")) |
| + if sameOneOh(t, rig, ctx, layout+"/default.html", oneOhLayoutLegs(scheme, "screen")) { |
| + covered = true |
| + } |
| + } |
| + // Day draws the ring and its shadow in both schemes, so a Day part |
| + // where the exception covered no change is one where the fix is |
| + // gone or the exception has drifted off it. |
| + if theme == "day" && !covered { |
| + t.Errorf("day %s: consoleRailRing covered no change in any layout; %s, and an exception for nothing is a hole waiting to be used", scheme, consoleRailRing.Why) |
| } |
| if theme != "day" || scheme != "light" { |
| return |
diff --git a/check/parity_browser_test.go b/check/parity_browser_test.go| index 5ea6fff..db51caf 100644 |
| --- a/check/parity_browser_test.go |
| +++ b/check/parity_browser_test.go |
| @@ -3,8 +3,14 @@ |
| package check |
| import ( |
| + "bytes" |
| "context" |
| "fmt" |
| + "image" |
| + "image/color" |
| + "image/draw" |
| + "image/png" |
| + "math" |
| "net/http" |
| "os" |
| "sort" |
| @@ -89,6 +95,119 @@ func parityAssets(tokens, theme, shell []byte) map[string][]byte { |
| return assets |
| } |
| +// renderingException is a change a 1.x release makes on purpose to |
| +// how 1.0 markup draws, which the photographs let through: the elements |
| +// it covers, the media query under which it applies, how far past their |
| +// border box it paints, and why. The areas are blanked on both sides |
| +// and must sit in the same place on both, so an exception lets a change |
| +// of paint through and never a change of layout; every pixel outside |
| +// them is still held to 1.0's. |
| +type renderingException struct { |
| + Selector, Media string |
| + Reach int |
| + Why string |
| +} |
| + |
| +// consoleRailRing is the only one. A nav that scrolls clips at its |
| +// padding box, so 1.0's console rail cut its current item's ring and |
| +// contact shadow off at the nav's edges. 1.2.0 gives the nav 5px of |
| +// padding and takes it back with negative margins, a smaller sticky |
| +// inset and a larger cap (Rastrillo cc7c9c69), so the ring draws whole |
| +// and no item moves. The rule is under (min-width: 800px), where the |
| +// rail is beside the page. |
| +var consoleRailRing = &renderingException{ |
| + Selector: ".rst-shell-console > .rst-shell__rail > .rst-shell__nav a[aria-current], " + |
| + "[rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] a[aria-current]", |
| + Media: "(min-width: 800px)", |
| + Reach: 5, |
| + Why: "1.2.0 draws the console rail's current item's ring and shadow " + |
| + "whole, where 1.0 clipped them at the nav's edges", |
| +} |
| + |
| +// areas reads, on the settled page, the border box of every element the |
| +// exception covers, grown by its reach, in the page's pixels. Where its |
| +// media query does not match, the exception covers nothing. |
| +func (e *renderingException) areas(t *testing.T, ctx context.Context) []image.Rectangle { |
| + t.Helper() |
| + var boxes []struct{ X, Y, W, H float64 } |
| + at(t, ctx, `JSON.stringify(!matchMedia(`+jsStr(e.Media)+`).matches ? [] : |
| + [...document.querySelectorAll(`+jsStr(e.Selector)+`)].map(element => { |
| + const box = element.getBoundingClientRect(); |
| + return {X: box.left + scrollX, Y: box.top + scrollY, W: box.width, H: box.height}; |
| + }).filter(box => box.W > 0 && box.H > 0))`, &boxes) |
| + var out []image.Rectangle |
| + for _, box := range boxes { |
| + out = append(out, image.Rect( |
| + int(math.Floor(box.X))-e.Reach, int(math.Floor(box.Y))-e.Reach, |
| + int(math.Ceil(box.X+box.W))+e.Reach, int(math.Ceil(box.Y+box.H))+e.Reach)) |
| + } |
| + return out |
| +} |
| + |
| +// blanked is img with every area painted one flat colour, so whatever |
| +// either side drew there compares equal. |
| +func blanked(img image.Image, areas []image.Rectangle) image.Image { |
| + if len(areas) == 0 { |
| + return img |
| + } |
| + out := image.NewRGBA(img.Bounds()) |
| + draw.Draw(out, out.Bounds(), img, img.Bounds().Min, draw.Src) |
| + for _, area := range areas { |
| + draw.Draw(out, area.Intersect(out.Bounds()), image.NewUniform(color.RGBA{255, 0, 255, 255}), image.Point{}, draw.Src) |
| + } |
| + return out |
| +} |
| + |
| +// sameAreas says why two sides' excepted areas differ, or "" when they |
| +// are the same: an excepted element that moved is a change of layout, |
| +// which no exception lets through. |
| +func sameAreas(was, is []image.Rectangle) string { |
| + if fmt.Sprint(was) != fmt.Sprint(is) { |
| + return fmt.Sprintf("the excepted areas moved: %v under 1.0.0's CSS, %v under the tree's", was, is) |
| + } |
| + return "" |
| +} |
| + |
| +// samePixelsExcept compares two page shots to the pixel with the |
| +// excepted areas blanked on both, after holding the areas to the same |
| +// place. covered says whether anything inside them differed, so a test |
| +// can tell an exception that let a real change through from one that |
| +// covered nothing. |
| +func samePixelsExcept(t *testing.T, was, is []byte, wasAreas, isAreas []image.Rectangle) (ok bool, why string, covered bool) { |
| + t.Helper() |
| + if why := sameAreas(wasAreas, isAreas); why != "" { |
| + return false, why, false |
| + } |
| + before, after := decodePNG(t, was), decodePNG(t, is) |
| + for _, area := range wasAreas { |
| + if ok, _ := samePixelsWithin(t, cropPNG(t, before, float64(area.Min.X), float64(area.Min.Y), float64(area.Dx()), float64(area.Dy()), 0), |
| + cropPNG(t, after, float64(area.Min.X), float64(area.Min.Y), float64(area.Dx()), float64(area.Dy()), 0), 0); !ok { |
| + covered = true |
| + } |
| + } |
| + ok, why = samePixelsWithin(t, encodePNG(t, blanked(before, wasAreas)), encodePNG(t, blanked(after, isAreas)), 0) |
| + return ok, why, covered |
| +} |
| + |
| +// encodePNG is img as PNG bytes, for the comparisons that read them. |
| +func encodePNG(t *testing.T, img image.Image) []byte { |
| + t.Helper() |
| + var out bytes.Buffer |
| + if err := png.Encode(&out, img); err != nil { |
| + t.Fatal(err) |
| + } |
| + return out.Bytes() |
| +} |
| + |
| +// pageShotExcept is pageShot that also returns the areas exception |
| +// covers on the settled page. |
| +func pageShotExcept(t *testing.T, ctx context.Context, url string, width int64, scheme string, exception *renderingException) ([]byte, []image.Rectangle) { |
| + t.Helper() |
| + var areas []image.Rectangle |
| + shot := pageShotChecked(t, ctx, url, width, scheme, func() { areas = exception.areas(t, ctx) }) |
| + return shot, areas |
| +} |
| + |
| // seedFile reads one file of the synced baseline, testdata/seed/. |
| func seedFile(t *testing.T, name string) []byte { |
| t.Helper() |
| @@ -162,9 +281,13 @@ func TestEveryThemeAndShellRendersAsTheBaseline(t *testing.T) { |
| rig := parityRig(t, docs, old, now) |
| ctx, cancel := context.WithTimeout(rig.Context(), 300*time.Second) |
| defer cancel() |
| + covered := false |
| for _, scheme := range []string{"light", "dark"} { |
| - was := matrixShots(t, ctx, rig.Origin, "/old/depth", scheme) |
| - is := matrixShots(t, ctx, rig.Origin, "/new/depth", scheme) |
| + was, wasAreas := matrixShotsExcept(t, ctx, rig.Origin, "/old/depth", scheme, consoleRailRing) |
| + is, isAreas := matrixShotsExcept(t, ctx, rig.Origin, "/new/depth", scheme, consoleRailRing) |
| + if why := sameAreas(wasAreas, isAreas); why != "" { |
| + t.Errorf("%s: the depth fixture in %s: %s", theme, scheme, why) |
| + } |
| keys := make([]string, 0, len(was)) |
| for key := range was { |
| keys = append(keys, key) |
| @@ -180,14 +303,69 @@ func TestEveryThemeAndShellRendersAsTheBaseline(t *testing.T) { |
| } |
| for _, layout := range layouts { |
| for _, width := range []int64{1280, 390} { |
| - before := pageShot(t, ctx, rig.Origin+"/old/"+layout, width, scheme) |
| - after := pageShot(t, ctx, rig.Origin+"/new/"+layout, width, scheme) |
| - if ok, why := samePixelsWithin(t, before, after, 0); !ok { |
| + before, beforeAreas := pageShotExcept(t, ctx, rig.Origin+"/old/"+layout, width, scheme, consoleRailRing) |
| + after, afterAreas := pageShotExcept(t, ctx, rig.Origin+"/new/"+layout, width, scheme, consoleRailRing) |
| + ok, why, changed := samePixelsExcept(t, before, after, beforeAreas, afterAreas) |
| + if !ok { |
| t.Errorf("%s: %s at %dpx in %s renders differently from the baseline: %s", theme, layout, width, scheme, why) |
| } |
| + covered = covered || changed |
| } |
| } |
| } |
| + // Day draws the ring and its shadow, so a Day run where the |
| + // exception covered no change is one where the fix is gone |
| + // or the exception has drifted off it. |
| + if theme == "day" && !covered { |
| + t.Errorf("day: consoleRailRing covered no change in any layout; %s, and an exception for nothing is a hole waiting to be used", consoleRailRing.Why) |
| + } |
| }) |
| } |
| } |
| + |
| +// TestTheConsoleRingExceptionLetsNoLayoutThrough is consoleRailRing's |
| +// negative control. Served the tree's tokens.css without the negative |
| +// margins that give the nav's new padding back, the console rail's items |
| +// move 5px, and the comparison must fail on the move however the blanked |
| +// areas compare. The tree's own tokens.css must pass, covering a change, |
| +// so the control is not passing by failing everything. |
| +func TestTheConsoleRingExceptionLetsNoLayoutThrough(t *testing.T) { |
| + const giveBack = "margin-block: -5px; margin-inline: -5px; " |
| + tokens := string(fiesta.TokensCSS()) |
| + if n := strings.Count(tokens, giveBack); n != 1 { |
| + t.Fatalf("tokens.css holds %q %d times, want once, in the console rail's nav rule", giveBack, n) |
| + } |
| + moved := []byte(strings.ReplaceAll(tokens, giveBack, "")) |
| + theme, _ := fiesta.ThemeCSS("day") |
| + const layout = "layout-console" |
| + docs := map[string]string{layout: parityLinks(string(seedFile(t, "examples/"+layout+"/default.html")))} |
| + for _, c := range []struct { |
| + name string |
| + tokens []byte |
| + passes bool |
| + }{ |
| + {"the tree's tokens.css", fiesta.TokensCSS(), true}, |
| + {"tokens.css without the margins", moved, false}, |
| + } { |
| + old := parityAssets(seedFile(t, "tokens.css"), seedFile(t, "day.css"), seedFile(t, "shell.css")) |
| + rig := parityRig(t, docs, old, parityAssets(c.tokens, theme, fiesta.ShellCSS())) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + before, beforeAreas := pageShotExcept(t, ctx, rig.Origin+"/old/"+layout, 1280, "light", consoleRailRing) |
| + after, afterAreas := pageShotExcept(t, ctx, rig.Origin+"/new/"+layout, 1280, "light", consoleRailRing) |
| + cancel() |
| + if len(beforeAreas) == 0 { |
| + t.Fatalf("%s: consoleRailRing covers nothing on 1.0.0's %s at 1280px; the control proves nothing", c.name, layout) |
| + } |
| + ok, why, covered := samePixelsExcept(t, before, after, beforeAreas, afterAreas) |
| + switch { |
| + case c.passes && !ok: |
| + t.Errorf("%s: %s fails the comparison: %s", c.name, layout, why) |
| + case c.passes && !covered: |
| + t.Errorf("%s: consoleRailRing covered no change on %s", c.name, layout) |
| + case !c.passes && ok: |
| + t.Errorf("%s: the console rail's items moved and the comparison passed; the exception lets layout through", c.name) |
| + default: |
| + t.Logf("%s: %v %s", c.name, ok, why) |
| + } |
| + } |
| +} |
diff --git a/css/tokens.css b/css/tokens.css| index 3e70e89..6abd02f 100644 |
| --- a/css/tokens.css |
| +++ b/css/tokens.css |
| @@ -3097,8 +3097,13 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher |
| the gate that missed it asked where the person sat inside the rail |
| — a question that stayed true while the rail hung off the screen. |
| TestTheConsoleRailFitsTheViewport asks the other question, and |
| - carries a control page that must answer it the other way. */ |
| - .rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: var(--rst-sp-4); max-block-size: calc(100dvh - 2 * var(--rst-sp-4)); overflow-y: auto; position: sticky; } |
| + carries a control page that must answer it the other way. |
| + THE RING. overflow-y: auto clips the nav at its padding box on |
| + both axes, which cut the current item's ring and contact shadow |
| + off at the nav's edges. The 5px of padding is room for the shadow |
| + at any light position, and the negative margin, inset and cap give |
| + it back so nothing moves (TestTheConsoleRailDrawsTheCurrentItemWhole). */ |
| + .rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: calc(var(--rst-sp-4) - 5px); margin-block: -5px; margin-inline: -5px; max-block-size: calc(100dvh - 2 * var(--rst-sp-4) + 10px); overflow-y: auto; padding-block: 5px; padding-inline: 5px; position: sticky; } |
| } |
| /* The topbar's narrow menu is a floating card: it overlays the page and |
diff --git a/releases/1.2.0/SHA256SUMS b/releases/1.2.0/SHA256SUMS| index 51ff871..8bfaff5 100644 |
| --- a/releases/1.2.0/SHA256SUMS |
| +++ b/releases/1.2.0/SHA256SUMS |
| @@ -6,9 +6,9 @@ a085f3eabd8556baebcfee2032f57464282549c65ac49ff66f3f65b2f8bc6f49 calendar.js |
| 92e7423f7275d543784ccd9529438b35800b965ba9b64737d0be6322b1c267ab copy.js |
| f21d680c67d9a2b874109824826f8ea2c4449be556fcf458bf250c955a96bc51 datetime.js |
| f6f0380d9773393adaa93f33fe446b633ccd5f15b3c53a539800e989862b9175 day.css |
| -40a9c6fa5e4317b31f9030738fa411631b5ee1bef535af920bd219f3e425f79b fiesta-plain.css |
| -2dcbb1106879332b5166b2e8bd50c453da3fcbd42174a819dd774a11e2356f89 fiesta-signal.css |
| -bf2125b084aa80daebf0196eb839400f10085aa07e41871f79a20f550f53be37 fiesta.css |
| +ba0b0c6e8dcf4d3bc801d9ff46c6b70c9d2225d8dba89eb513450185e20200bf fiesta-plain.css |
| +6c59dcc84428b86d31257816bde900af9e47ed09ede3939501250b928f009cf7 fiesta-signal.css |
| +245bf5329c8e7d8e3f669cc2cb9223b12a13db0b8461eee7e99ed924dbf2d375 fiesta.css |
| 362c17e2d02d99c42dbb78b8129d86c1635b7a5b9336edebcf7594fe3cfadd8c fiesta.js |
| 1b012fc080fef1a904a6c42e4cff9a81f6fb406e19237395754454b87252b034 i18n/ar.js |
| b1c44c6727f2b192e7506ebd8fddee088b9e8a98b9e09b159b8f77085473b129 i18n/ar.json |
| @@ -53,4 +53,4 @@ f9da2afe8f29dd30217e209e850487123906dc73190584587c7d28dc08652545 select.js |
| 2a6c6a0284d9faddb68d1a3b79377c6a1c6144c1e07e7fde2f81dcf6e32e9e2e shell.css |
| 4e1e4ba193a1e5f08901b998b5501918a91775ba572a2cc115a35d9556cac653 shell.js |
| f75da06dfc8fff7d59eeed29050506e1e2fd5ecf60e99c25ab45260abe31076d signal.css |
| -b7a7eba92c6ca2e74491f42918a37ccc821f32da328cb807519ef8eeb8d3131c tokens.css |
| +064412f4dc68300359d122eef587bab1be650edcccf158892e170867407c7214 tokens.css |
diff --git a/releases/1.2.0/fiesta-plain.css b/releases/1.2.0/fiesta-plain.css| index dcc030e..1f60648 100644 |
| --- a/releases/1.2.0/fiesta-plain.css |
| +++ b/releases/1.2.0/fiesta-plain.css |
| @@ -1354,7 +1354,7 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher |
| .rst-shell-console > .rst-shell__rail, [rst-shell-console] > [rst-shell-rail] { border-block-end: 0; border-inline-end: 1px solid var(--rst-line); display: flex; grid-area: rail; } |
| .rst-shell-console:not(.rst-shell-console--page, .rst-shell-console--index):has(.rst-shell__menu:not([open])) > .rst-shell__rail, |
| [rst-shell-console]:not([rst-shell-console~="page"], [rst-shell-console~="index"]):has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: flex; } |
| - .rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: var(--rst-sp-4); max-block-size: calc(100dvh - 2 * var(--rst-sp-4)); overflow-y: auto; position: sticky; } |
| + .rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: calc(var(--rst-sp-4) - 5px); margin-block: -5px; margin-inline: -5px; max-block-size: calc(100dvh - 2 * var(--rst-sp-4) + 10px); overflow-y: auto; padding-block: 5px; padding-inline: 5px; position: sticky; } |
| } |
| @media (max-width: 799.98px) { |
diff --git a/releases/1.2.0/fiesta-signal.css b/releases/1.2.0/fiesta-signal.css| index 9dd841b..dd28394 100644 |
| --- a/releases/1.2.0/fiesta-signal.css |
| +++ b/releases/1.2.0/fiesta-signal.css |
| @@ -1354,7 +1354,7 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher |
| .rst-shell-console > .rst-shell__rail, [rst-shell-console] > [rst-shell-rail] { border-block-end: 0; border-inline-end: 1px solid var(--rst-line); display: flex; grid-area: rail; } |
| .rst-shell-console:not(.rst-shell-console--page, .rst-shell-console--index):has(.rst-shell__menu:not([open])) > .rst-shell__rail, |
| [rst-shell-console]:not([rst-shell-console~="page"], [rst-shell-console~="index"]):has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: flex; } |
| - .rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: var(--rst-sp-4); max-block-size: calc(100dvh - 2 * var(--rst-sp-4)); overflow-y: auto; position: sticky; } |
| + .rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: calc(var(--rst-sp-4) - 5px); margin-block: -5px; margin-inline: -5px; max-block-size: calc(100dvh - 2 * var(--rst-sp-4) + 10px); overflow-y: auto; padding-block: 5px; padding-inline: 5px; position: sticky; } |
| } |
| @media (max-width: 799.98px) { |
diff --git a/releases/1.2.0/fiesta.css b/releases/1.2.0/fiesta.css| index 41fdba1..fcb1d76 100644 |
| --- a/releases/1.2.0/fiesta.css |
| +++ b/releases/1.2.0/fiesta.css |
| @@ -1354,7 +1354,7 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher |
| .rst-shell-console > .rst-shell__rail, [rst-shell-console] > [rst-shell-rail] { border-block-end: 0; border-inline-end: 1px solid var(--rst-line); display: flex; grid-area: rail; } |
| .rst-shell-console:not(.rst-shell-console--page, .rst-shell-console--index):has(.rst-shell__menu:not([open])) > .rst-shell__rail, |
| [rst-shell-console]:not([rst-shell-console~="page"], [rst-shell-console~="index"]):has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: flex; } |
| - .rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: var(--rst-sp-4); max-block-size: calc(100dvh - 2 * var(--rst-sp-4)); overflow-y: auto; position: sticky; } |
| + .rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: calc(var(--rst-sp-4) - 5px); margin-block: -5px; margin-inline: -5px; max-block-size: calc(100dvh - 2 * var(--rst-sp-4) + 10px); overflow-y: auto; padding-block: 5px; padding-inline: 5px; position: sticky; } |
| } |
| @media (max-width: 799.98px) { |
diff --git a/releases/1.2.0/tokens.css b/releases/1.2.0/tokens.css| index f8a9e07..7b2b8c6 100644 |
| --- a/releases/1.2.0/tokens.css |
| +++ b/releases/1.2.0/tokens.css |
| @@ -1354,7 +1354,7 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher |
| .rst-shell-console > .rst-shell__rail, [rst-shell-console] > [rst-shell-rail] { border-block-end: 0; border-inline-end: 1px solid var(--rst-line); display: flex; grid-area: rail; } |
| .rst-shell-console:not(.rst-shell-console--page, .rst-shell-console--index):has(.rst-shell__menu:not([open])) > .rst-shell__rail, |
| [rst-shell-console]:not([rst-shell-console~="page"], [rst-shell-console~="index"]):has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: flex; } |
| - .rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: var(--rst-sp-4); max-block-size: calc(100dvh - 2 * var(--rst-sp-4)); overflow-y: auto; position: sticky; } |
| + .rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: calc(var(--rst-sp-4) - 5px); margin-block: -5px; margin-inline: -5px; max-block-size: calc(100dvh - 2 * var(--rst-sp-4) + 10px); overflow-y: auto; padding-block: 5px; padding-inline: 5px; position: sticky; } |
| } |
| @media (max-width: 799.98px) { |