| 1 | package guards |
| 2 | |
| 3 | import ( |
| 4 | "regexp" |
| 5 | "sort" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // This is the guard for a bug that made every menu in the app open |
| 11 | // against the right-hand edge of the viewport while both stylesheets |
| 12 | // read as correct. |
| 13 | // |
| 14 | // rastrillo v0.23.0 places a dropdown panel with CSS anchor |
| 15 | // positioning: `position: fixed`, `position-anchor: --rst-menu`, a |
| 16 | // `position-area`, and `inset: auto` so that the area does the placing. |
| 17 | // Its pre-anchor fallback — `position: absolute; top: 100%; |
| 18 | // inset-inline-end: 0` — is still in the file for engines without it. |
| 19 | // |
| 20 | // Sheets overrides that fallback, because rastrillo's panel opens |
| 21 | // leftward (right for the account menu it was written for) and a menu |
| 22 | // bar opens rightward. The override was written as `inset-inline-start: |
| 23 | // 0; inset-inline-end: auto` at `[data-menubar] [rst-dropdown-menu]`, |
| 24 | // which is (0,2,0) — and rastrillo's `inset: auto` is (0,1,0). So ours |
| 25 | // won inside the @supports branch too, where it means something else |
| 26 | // entirely: an anchored, fixed panel with a resolved inline-start inset |
| 27 | // takes the VIEWPORT as its containing block. Measured on 2026-09-03, |
| 28 | // all six menus opened at x=1015 on a 1280px viewport, whichever word |
| 29 | // was clicked. |
| 30 | // |
| 31 | // Nothing could see it. The Go suite passes, the CSS parses, every |
| 32 | // token is real, and each of the two declarations is individually the |
| 33 | // right thing to write. Only a browser measuring a rectangle finds it, |
| 34 | // and CI has no browser. |
| 35 | // |
| 36 | // So the invariant is structural, and it is the one thing that was |
| 37 | // missing: an override of rastrillo's PRE-ANCHOR insets must be paired |
| 38 | // with an anchor-branch rule for the same selector that hands the |
| 39 | // placing back. Adding a third menu-ish panel and styling it the |
| 40 | // obvious way now fails here rather than in a screenshot nobody takes. |
| 41 | const sheetsCSS = "internal/sheets/static/sheets.css" |
| 42 | |
| 43 | var ( |
| 44 | // A rule head plus its body, non-greedy. sheets.css nests only |
| 45 | // inside @supports, and stripBlocks below removes those first, so a |
| 46 | // body never contains a brace. |
| 47 | cssRule = regexp.MustCompile(`(?s)([^{}@]+)\{([^{}]*)\}`) |
| 48 | // The @supports blocks, with their condition and their contents. |
| 49 | cssSupports = regexp.MustCompile(`(?s)@supports([^{]*)\{(.*?\n\})\s*\n`) |
| 50 | cssComment = regexp.MustCompile(`(?s)/\*.*?\*/`) |
| 51 | insetLogical = regexp.MustCompile(`(?m)^\s*inset-inline-(start|end)\s*:`) |
| 52 | insetAuto = regexp.MustCompile(`(?m)^\s*inset\s*:\s*auto\s*;`) |
| 53 | // The declaration that actually does the placing. Requiring |
| 54 | // `inset: auto` alone was the guard's own blind spot: a rule that |
| 55 | // hands placement back and then says nothing about WHERE leaves |
| 56 | // rastrillo's own area in force, which opens a menu bar's menus |
| 57 | // leftward off the left edge of the window. |
| 58 | positionArea = regexp.MustCompile(`(?m)^\s*position-area\s*:\s*\S`) |
| 59 | ) |
| 60 | |
| 61 | // selectorsOverridingPanelInsets returns every selector in the given |
| 62 | // CSS that targets a rastrillo dropdown panel and sets one of the |
| 63 | // logical insets rastrillo's pre-anchor fallback uses. |
| 64 | func selectorsOverridingPanelInsets(css string) []string { |
| 65 | var out []string |
| 66 | for _, m := range cssRule.FindAllStringSubmatch(css, -1) { |
| 67 | sel := strings.Join(strings.Fields(m[1]), " ") |
| 68 | if !strings.Contains(sel, "[rst-dropdown-menu]") { |
| 69 | continue |
| 70 | } |
| 71 | if insetLogical.MatchString(m[2]) { |
| 72 | out = append(out, sel) |
| 73 | } |
| 74 | } |
| 75 | sort.Strings(out) |
| 76 | return out |
| 77 | } |
| 78 | |
| 79 | // anchorBranchSelectors returns every selector that appears inside an |
| 80 | // @supports block testing position-area, hands placement back with |
| 81 | // `inset: auto`, AND says where the panel goes. |
| 82 | // |
| 83 | // Both declarations are required. The first version checked only |
| 84 | // `inset: auto`, so deleting `position-area: block-end span-inline-end` |
| 85 | // — the one line that makes a menu bar open rightward — left this |
| 86 | // green while removing exactly what the failure message tells the next |
| 87 | // person to add. |
| 88 | func anchorBranchSelectors(css string) map[string]bool { |
| 89 | out := map[string]bool{} |
| 90 | for _, blk := range cssSupports.FindAllStringSubmatch(css, -1) { |
| 91 | if !strings.Contains(blk[1], "position-area") { |
| 92 | continue |
| 93 | } |
| 94 | for _, m := range cssRule.FindAllStringSubmatch(blk[2], -1) { |
| 95 | if !insetAuto.MatchString(m[2]) || !positionArea.MatchString(m[2]) { |
| 96 | continue |
| 97 | } |
| 98 | out[strings.Join(strings.Fields(m[1]), " ")] = true |
| 99 | } |
| 100 | } |
| 101 | return out |
| 102 | } |
| 103 | |
| 104 | func TestEveryDropdownInsetOverrideHasAnAnchorBranch(t *testing.T) { |
| 105 | css := cssComment.ReplaceAllString(readRepoFile(t, repoRoot(t), sheetsCSS), "") |
| 106 | |
| 107 | overrides := selectorsOverridingPanelInsets(css) |
| 108 | if len(overrides) == 0 { |
| 109 | t.Fatalf("guards: found no [rst-dropdown-menu] inset override in %s. "+ |
| 110 | "Either the app stopped overriding rastrillo's placement — in which "+ |
| 111 | "case delete this test and say why — or the parser above has gone "+ |
| 112 | "blind and is now proving nothing.", sheetsCSS) |
| 113 | } |
| 114 | |
| 115 | anchored := anchorBranchSelectors(css) |
| 116 | if len(anchored) == 0 { |
| 117 | t.Fatalf("guards: %s overrides %d dropdown panel inset(s) and declares no "+ |
| 118 | "@supports (position-area …) branch at all. In Chromium those "+ |
| 119 | "overrides do not adjust rastrillo's placement, they replace it with "+ |
| 120 | "the viewport: every menu opens flush against one edge of the window "+ |
| 121 | "whichever word was clicked.", sheetsCSS, len(overrides)) |
| 122 | } |
| 123 | |
| 124 | for _, sel := range overrides { |
| 125 | if !anchored[sel] { |
| 126 | t.Errorf("%s sets inset-inline-* on %q but no @supports "+ |
| 127 | "(position-area …) block resets `inset: auto` for that same "+ |
| 128 | "selector.\n"+ |
| 129 | "That override is written against rastrillo's PRE-ANCHOR "+ |
| 130 | "fallback. Where anchor positioning exists it outranks "+ |
| 131 | "rastrillo's own `inset: auto` on specificity — (0,2,0) beats "+ |
| 132 | "(0,1,0) — and an anchored fixed panel with a resolved inset "+ |
| 133 | "is placed against the viewport, not against the summary it "+ |
| 134 | "hangs off.\n"+ |
| 135 | "Pair it: keep this rule for the fallback, and add\n"+ |
| 136 | " @supports (position-area: block-end) and "+ |
| 137 | "(position-try-fallbacks: flip-block) {\n"+ |
| 138 | " %s { inset: auto; position-area: block-end span-inline-end; }\n"+ |
| 139 | " }", |
| 140 | sheetsCSS, sel, sel) |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // TestTheAnchorGuardReadsTheStylesheetItThinksItDoes is the mutation |
| 146 | // check for the two parsers above. |
| 147 | // |
| 148 | // Both read CSS with a regex, which is the kind of check that silently |
| 149 | // stops matching after a reformat and then passes forever. This feeds |
| 150 | // them CSS with the bug present and requires them to see it, so a |
| 151 | // parser that has gone blind fails here and names itself rather than |
| 152 | // turning the test above into a no-op that nobody notices. |
| 153 | func TestTheAnchorGuardReadsTheStylesheetItThinksItDoes(t *testing.T) { |
| 154 | const broken = ` |
| 155 | [data-menubar] [rst-dropdown-menu] { |
| 156 | inset-inline-start: 0; |
| 157 | inset-inline-end: auto; |
| 158 | } |
| 159 | ` |
| 160 | if got := selectorsOverridingPanelInsets(broken); len(got) != 1 { |
| 161 | t.Fatalf("the override parser did not see the exact shape of the shipped "+ |
| 162 | "bug: got %v, want one selector", got) |
| 163 | } |
| 164 | if got := anchorBranchSelectors(broken); len(got) != 0 { |
| 165 | t.Fatalf("the anchor-branch parser invented a branch that is not there: %v", got) |
| 166 | } |
| 167 | |
| 168 | const fixed = broken + ` |
| 169 | @supports (position-area: block-end) and (position-try-fallbacks: flip-block) { |
| 170 | [data-menubar] [rst-dropdown-menu] { |
| 171 | inset: auto; |
| 172 | position-area: block-end span-inline-end; |
| 173 | } |
| 174 | } |
| 175 | ` |
| 176 | if got := anchorBranchSelectors(fixed); !got["[data-menubar] [rst-dropdown-menu]"] { |
| 177 | t.Fatalf("the anchor-branch parser did not find the paired branch: %v", got) |
| 178 | } |
| 179 | |
| 180 | // A branch that hands placement back and does not say where must |
| 181 | // not count. This is the mutation the first version of the guard |
| 182 | // could not see. |
| 183 | const noArea = broken + ` |
| 184 | @supports (position-area: block-end) and (position-try-fallbacks: flip-block) { |
| 185 | [data-menubar] [rst-dropdown-menu] { |
| 186 | inset: auto; |
| 187 | } |
| 188 | } |
| 189 | ` |
| 190 | if got := anchorBranchSelectors(noArea); len(got) != 0 { |
| 191 | t.Fatalf("a branch with no position-area was accepted: %v", got) |
| 192 | } |
| 193 | |
| 194 | // A branch guarded by some OTHER @supports condition must not count. |
| 195 | const wrongCondition = broken + ` |
| 196 | @supports (display: grid) { |
| 197 | [data-menubar] [rst-dropdown-menu] { |
| 198 | inset: auto; |
| 199 | } |
| 200 | } |
| 201 | ` |
| 202 | if got := anchorBranchSelectors(wrongCondition); len(got) != 0 { |
| 203 | t.Fatalf("an @supports block that does not test position-area was accepted "+ |
| 204 | "as the anchor branch: %v", got) |
| 205 | } |
| 206 | } |
| 207 | |