Guides
The conformance matrix
Some review findings come back again and again: focus lost when a control disappears, server HTML that a reader in another time zone or locale cannot hydrate, a part on the wrong side in a right-to-left page, a control that vanishes under forced colours, a message announced twice. The conformance matrix turns each of them into a check that runs on every story, so a new component is covered the day it gets a story, with nothing to opt into and no checklist to remember.matrix
pnpm conformance # every check, every story (Chromium)
pnpm conformance test/conformance/rtl.test.tsx # one check
pnpm conformance -t "SegmentedControl/" # one component's stories, every check
OURO_STORIES=pagination pnpm conformance # the stories of the folders a change touches
OURO_CONFORMANCE_SHARD=2/4 pnpm conformance # one CI shard: every 4th story from the 2ndThe checks live in test/conformance/ and run in their own Vitest project, conformance (Chromium only, OURO_CONFORMANCE=1), so pnpm test leaves them out. CI's conformance (n/3) jobs run them in three shards (OURO_CONFORMANCE_SHARD), each shard every *n*th story, so the shards take about as long as each other, and the conformance job, the check the rules require, passes when every shard did.shards On a pull request the shards render only the stories scripts/test-impact.mjs selects, as the other shared suites do.
The checks
- Focus lost when a control disappears
- Check: Each story's controls are used one after another: a press on each button, link, checkbox, tab or option (Enter for one shown only to screen readers), a character typed into each field, an arrow key on each slider and scrollable region, and Escape for each popup the actions open.interactions After every action, focus must be on an element still in the page and shown.interactions
interactions.test.tsx- A message announced twice
- Check: Every one of those actions runs inside
watchAnnouncements: the same text announced twice in one action fails, whether one live region says it twice or two regions say it once each.interactions interactions.test.tsx- Server/client mismatch
- Check: Every story is rendered to HTML as a server in UTC and en-US would, then hydrated in America/Los_Angeles with de-DE and in Asia/Tokyo with ja-JP.hydration The server and each reader are separate frames (
frame.tsx), each with its own copy of every module, so a formatter cached under one locale is never read under another.hydration A recoverable error or an attribute mismatch fails. hydration.test.tsx- Right to left
- Check: Every story renders with
dir="rtl"on<html>, closed and with each overlay open, at 390px and 1280px: nothing may stick out of the viewport sideways.rtl rtl.test.tsx- Physical sides in the source
- Check:
physicalUtilitiesinscripts/source-rules.mjsfails onleft-*,right-*,ml-*,mr-*,pl-*,pr-*,border-l,border-r,rounded-l-*,rounded-r-*, the corner radii,text-left,text-right,float-*andclear-*on a side, unlessphysicalAllowedlists the class with its reason. It also fails on a horizontal translate (translate-x-*), a transform origin, a background position or an object position on a side (origin-left,bg-right,object-left-top), which have no logical form, unless the same class list reverses it for right to left with anrtl:class of the same kind (translate-x-4 rtl:-translate-x-4,origin-left rtl:origin-right).physical scripts/source-rules.test.mjs- Forced colours
- Check: Under
forced-colors: active, closed and with each overlay open: every box a person presses, types into, drags or scrolls keeps an edge (a border or outline on it, inside it or on its component's frame); a checked, selected, pressed or current control looks different from one that is not; and focusing each control from the keyboard changes what its component shows, while the highlight moving onto each option or item of a list or menu shows too.forced A focused control is compared with its component at rest (focus outside it); a component never at rest (a menu opened with focus inside) is compared with the control that had focus left out, together with every part around it that does not hold the new one (a wrapper that draws its ring with:has(:focus-visible)), so another control's ring going away is no cue.forced The only item of a list is compared with its highlight taken off. A fill in the colour behind it counts as no fill.forced A control that never takes keyboard focus fails as uncompared: focus that stays where it was, or goes to another kind of part, is no redirect; only a part that sends focus on to another of its kind (OtpField's slot to the first empty one) is compared at that one's turn.forced Controls that a focus shows (a tab's panel) are compared too. forced-colors.test.tsx
Motion is already held the same way, by pnpm motion:coverage.
Each check also proves it can fail.can-fail test/fixtures/conformance/broken.stories.tsx holds one story per defect, outside Storybook. Each check's "proves it can fail" tests expect it to report that story:can-fail
- a button that removes itself when pressed;
- the same text in two live regions;can-fail
- a number formatted through a module-level
Intl.NumberFormatcache; - a part placed at
left: 100%in a right-to-left page; - a focus ring drawn only with a box shadow;
- a button whose only edge is its fill;
- two buttons of one component, the second's focus ring a box shadow, so focus moving to it only takes the first's ring away;can-fail
- a part whose focus handler sends focus to another kind of part;
- the only option of a list, its highlight a fill.
These tests run in every conformance run, whatever stories it selects, on the first shard, and in CI even when a pull request changes no component (pnpm conformance -t "can fail").can-fail-ci A check changed so that it misses its defect fails there.
Why a check fails, and the fix
- **"focus fell to
<body>"**: the control you used left the page, or became disabled, while it held focus. A component that a press removes hands focus on before it goes:useFocusHandoff(registry/ouro/lib/ouro/focus-handoff.ts) moves it to what now stands where the element stood (Alert, Attachment and Toast use it). A control that becomes unavailable where it is says so witharia-disabledand keeps focus, as Pagination's Previous and Next do at the ends. When the story's own code removes the control (a demo's "show it again" button), keep the control on the page. - **A hydration mismatch**: render nothing that depends on the time zone, the locale, the clock or randomness on the server and in hydration. Read it with
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot), whose server snapshot is the same everywhere, and fill it in once hydrated (Calendar's today, Slider's value samples without alocale). A number or date formatted in the reader's default locale is written in a fixed one on the server and during hydration, then switches: Slider and Progress pass Base UIlocale="en-US"until hydrated, when nolocaleis given.locale A story that computes an instant fromDate.now()at module level rounds it (to the hour), since the server's frame and the reader's load the module at different moments. - **Overflow in a right-to-left page**: a part placed by a physical side lands on the wrong one. Use
start-*/end-*,ms-*/me-*,ps-*/pe-*,border-s/border-e,rounded-s/rounded-eandtext-start/text-end, andrtl:for a transform that must reverse (SegmentedControl's thumb). - **"no edge"**: forced colours drop fills and shadows.can-fail Add a border that only forced colours draw,
forced-colors:border forced-colors:border-solid, or for a scrollable region that is a tab stop, an outline:forced-colors:[&[tabindex='0']]:outline-1 forced-colors:[&[tabindex='0']]:outline-solid. - **"on looks like off"**: the state was a fill. Paint it with the system's pair,
forced-colors:data-pressed:bg-[Highlight] forced-colors:data-pressed:text-[HighlightText](Tabs' and SegmentedControl's selected item). - **"focus shows nothing"**: the focus ring was a box shadow. Draw it with
outline, which forced colours keep. - **"highlight shows nothing"**: an option's or item's highlight was a fill. Add
forced-colors:data-highlighted:bg-[Highlight] forced-colors:data-highlighted:text-[HighlightText](Menu, Select, Combobox, Command). On a link, addforced-colors:forced-color-adjust-nonewith the pair, since Chromium paints a link's background in the canvas colour whatever it is (Pagination's current page). - **"focus never compared"**: the control took no keyboard focus when focused, or kept it when blurred.forced Make it reachable from the keyboard: a tab stop, or a roving item its group moves focus onto.
Declaring an exception
When a story does something on purpose that a check would fail, it says so in its parameters.conformance (ConformanceParameters in test/stories.tsx), with the reason, and the check fails once the declaration is no longer truedeclared:
focusExit: { "<data-ouro-control>/<data-ouro-part> <accessible name>": "<why>" }: using that one control deliberately takes focus out of the page ("unmarked/? <name>"for a control the story renders itself). A declared control whose action keeps focus, or that no control is, fails, so the declaration cannot go stale.declaredannounces: { "<control's accessible name>": "<text>" }: one action on that control must announce the text exactly once.clientOnly: "<why>": the story cannot render on a server, so its hydration is not checked; once it does render on a server, the check fails until the declaration goes.
A physical side that must follow the screen, not the text (a part centred with left-1/2 -translate-x-1/2, a safe-area inset, a sheet whose side prop is physical), goes in scripts/physical-allowed.json (physicalAllowed), by file and class, with its reason; an allowance the source no longer uses fails.physical-allowed
Exemptions only grow on purpose. scripts/conformance-exemptions.mjs compares the physical-side allowances, every focusExit control and every clientOnly with the base branch, as the Conformance exemptions workflow does on each pull request (and again when its description is edited).exemptions Each one the pull request adds needs its reason, and the description must name it: the class, the control's key, or the story (SheetFeel/FeelComparison).exemptions So that none escapes it, a story writes parameters and conformance as object literals in place: a spread, a shorthand (parameters: { conformance }), a variable (focusExit: exits), a computed key, a reason that is not a plain string, or parameters set by assignment after the story fails the check.exemptions Its job, conformance exemptions, passes in the merge queue without checking, since the pull request's description was checked already.exemptions
Tests that cannot fail: mutation testing
A test that passes whatever the code does proves nothing. pnpm mutation (scripts/mutation.mjs) runs Stryker on the lines a change added or altered in the runtime source (registry/ouro/components/ and registry/ouro/lib/ouro/, not tests, stories or the test kit): it changes each of those lines in every way it knows (a condition flipped, a branch emptied, a + made a -), one change (a mutant) at a time, and runs the tests.mutation-run A mutant that no test fails on survived, and the run fails, naming it:mutation-survivors
pnpm mutation # the working tree against origin/main
pnpm mutation origin/main --only registry/ouro/lib/ouro/cn.ts # one file's changed linesEach survivor is reported as <file>:<line>:<column> <Mutator>: <replacement>.mutation-survivors Fix a survivor with a test that fails on that change. When the change cannot alter behaviour (an equivalent mutant), say why on the line before it, // Stryker disable next-line <Mutator>: <why>, so the reason is in the source for the next reader. Any other Stryker comment a change adds to the runtime source fails the run: disable all with no reason would silence the rest of the file.mutation-disables The run also reports how many disable comments the source has at the base and now, and fails if that count grew by more than the change shows.mutation-disables Two kinds of mutant are left out on purpose.mutation-ignored The first is Tailwind classes where only classes go (a className, a cn() or cva() argument, a *Class constant), which change how a part looks and which the screenshot tests hold. An attribute name, a selector or a marker that only looks like classes ("aria-invalid", closest("[inert]"), data-ouro-part) is still mutated. The second is the process.env.NODE_ENV check that strips development messages, which every test (they all run in development) sees the same way.mutation-ignored
The tests run in Chromium through scripts/mutation/plugins.mjs, a Stryker test runner over one Vitest instance. Each mutant runs only the tests of the file it is in: that file's own tests first (its component folder's, or a lib/ file's own test file), stopping at the first failure, then, only if those let it survive, the tests of the components that import that lib/ file.mutation-run A large change therefore costs each mutant no more than a small one.mutation-run Files that move keep their lines (git diff -M), so only what a move changed is mutated.
In CI
CI's mutation jobs run it on every pull request, over the pull request's changes.mutation-ci mutation-plan decides how many shards: five when no results are kept for the pull request, three when they are.mutation-plan pnpm mutation <base> --shard <i>/<n> takes the *i*th of *n* groups of the changed ranges (mutation (i/n)): a range, not a file, so one file with many slow mutants spreads across shards.mutation-cost A long range is cut into parts of a few mutants each, but only after a line that no node of the range runs past, since Stryker mutates a node only when one range holds all of it.mutation-split The groups are balanced by what they cost: each mutant one second and half its own tests' time, and each shard half the time of every test file it needs once, a lib/ file's importers included.mutation-cost The test times are measured: scripts/mutation/test-seconds.json holds each test file's time from a ci job's pnpm test (refresh it with node scripts/mutation/cache.mjs seconds <log>), and a file it lacks counts as five seconds.mutation-cost
Each shard starts from the results kept from the pull request's last run (Stryker's incremental mode, .tmp/stryker-incremental.json), so a push reruns only the mutants its change touched.mutation-cache The mutation job merges the shards' results into one file and keeps it in the cache under a key with a hash of every lib/ and theme file, the lockfile, app.css, the Vitest config and setup, the tests the mutants run against, and what all of these and the changed files import, directly or through each other (node scripts/mutation/cache.mjs key <base>): Stryker's incremental mode compares only a mutated file, so a result from before one of those changed starts afresh.mutation-cache A file whose own source changed since its results were kept starts afresh too, and so does a file that imports, directly or through other modules, a changed file of the pull request that changed since, since Stryker would otherwise keep a Killed on a line the change left alone (it compares only the mutated file, not what it imports).mutation-cache Each shard keeps only the mutants of its own ranges, since Stryker also writes every mutant it did not run with the status it last had; so a survivor stays Survived in the merge, and the kept results fail the next run too.mutation-cache The mutation job, the check the rules require, passes when every shard did; on a merge-queue run each shard has nothing to mutate.mutation-ci
Ratchets that cannot be talked around: property tests
The two guardrail kits hold a count that may only fall: ouro-source-rules the hand-rolled controls in an app's source, ouro-census the unmarked controls in each state of each journey.ratchets scripts/kits/ratchets.test.mjs checks with fast-check that no edit gets around them.ratchets It generates 300 baselines, scans, settings, renames and removals per property from a fixed seed, so a run is deterministic.ratchet-runs Each property states what must hold whatever the edit:ratchets
- The census accepts a run without a reason exactly when it drops no state and raises no state's unmarked count.ratchet-census Every rise and drop it accepts with a reason is recorded with that reason and the counts it moves between.ratchet-census A rename that adds unmarked controls needs a reason. Updating a baseline from itself records nothing.
- The source rule reports a settings change whenever the head's settings stop reading a file the base's settings read. A move stays within its file or component, so a fix elsewhere never pays for a new control: the regressions are never fewer than the controls the scan gained.ratchet-source Against a base ref, the check fails, and
--write-baselinewrites nothing, exactly when a file gained controls with no recorded reason.ratchet-source
A failing property prints the case fast-check shrank it to, and the seed. Add that case to the kit's own tests (scripts/kits/*.test.mjs) when you fix it. The property tests run in pnpm test:scripts (scripts/kits.test.mjs), under the kits' 100% coverage.ratchet-runs
Claim sources
- matrix: test:test/conformance/interactions.test.tsx::actions at $width px > $id keeps focus in the page and announces each message once after every action; test:test/conformance/hydration.test.tsx::server HTML hydrates in another time zone and locale > $id; test:test/conformance/rtl.test.tsx::right to left at $width px > $id stays inside the viewport, closed and with each overlay open; test:test/conformance/forced-colors.test.tsx::forced colours at $width px > $id keeps every control's edge, state and focus, closed and with each overlay open
verify:testExists(test/conformance/interactions.test.tsx::actions at $width px > $id keeps focus in the page and announces each message once after every action)verify:testExists(test/conformance/hydration.test.tsx::server HTML hydrates in another time zone and locale > $id)verify:testExists(test/conformance/rtl.test.tsx::right to left at $width px > $id stays inside the viewport, closed and with each overlay open)verify:testExists(test/conformance/forced-colors.test.tsx::forced colours at $width px > $id keeps every control's edge, state and focus, closed and with each overlay open) - shards: .github/workflows/ci.yml
verify:fileLine(.github/workflows/ci.yml, /OURO_CONFORMANCE_SHARD: \$\{\{ matrix\.shard \}\}\/\$\{\{ strategy\.job-total \}\}/)verify:testExists(scripts/workflows.test.mjs::conformance and mutation are each one check over their shards, which passes only when every shard passed)verify:testExists(scripts/workflows.test.mjs::the conformance shards run only the checks' proofs on the first shard when a pull request changes no component, and check only the selected stories, every nth per shard) - interactions: test:test/conformance/interactions.test.tsx::actions at $width px > $id keeps focus in the page and announces each message once after every action
verify:testExists(test/conformance/interactions.test.tsx::actions at $width px > $id keeps focus in the page and announces each message once after every action)verify:fileLine(test/conformance/interactions.test.tsx, /was announced \$\{times\} times/) - hydration: test:test/conformance/hydration.test.tsx::server HTML hydrates in another time zone and locale > $id
verify:testExists(test/conformance/hydration.test.tsx::server HTML hydrates in another time zone and locale > $id)verify:fileLine(test/conformance/hydration.test.tsx, /America\/Los_Angeles/)verify:fileLine(test/conformance/frame.tsx, /renderToString/) - rtl: test:test/conformance/rtl.test.tsx::right to left at $width px > $id stays inside the viewport, closed and with each overlay open
verify:testExists(test/conformance/rtl.test.tsx::right to left at $width px > $id stays inside the viewport, closed and with each overlay open) - physical: scripts/source-rules.test.mjs
verify:testExists(scripts/source-rules.test.mjs::physicalUtilities finds a horizontal translate, a transform origin, a background or object position on a side, unless the same classes reverse it for right to left) - forced: test:test/conformance/forced-colors.test.tsx::forced colours at $width px > $id keeps every control's edge, state and focus, closed and with each overlay open; test:test/conformance/forced-colors.test.tsx::forced colours proves it can fail > on $id
verify:testExists(test/conformance/forced-colors.test.tsx::forced colours at $width px > $id keeps every control's edge, state and focus, closed and with each overlay open)verify:testExists(test/conformance/forced-colors.test.tsx::forced colours proves it can fail > on $id) - can-fail: test:test/conformance/interactions.test.tsx::actions prove they can fail > on $id; test:test/conformance/hydration.test.tsx::hydration proves it can fail > on $id; test:test/conformance/rtl.test.tsx::right to left proves it can fail > on $id; test:test/conformance/forced-colors.test.tsx::forced colours proves it can fail > on $id
verify:testExists(test/conformance/interactions.test.tsx::actions prove they can fail > on $id)verify:testExists(test/conformance/hydration.test.tsx::hydration proves it can fail > on $id)verify:testExists(test/conformance/rtl.test.tsx::right to left proves it can fail > on $id)verify:testExists(test/conformance/forced-colors.test.tsx::forced colours proves it can fail > on $id) - can-fail-ci: .github/workflows/ci.yml
verify:fileLine(.github/workflows/ci.yml, /run: pnpm conformance -t "can fail"/)verify:fileLine(test/conformance/harness.ts, /\(parseShard\(inject\("ouroShard"\)\)\?\.index \?\? 1\) === 1/) - locale: registry/ouro/components/ouro/slider/slider.test.tsx; registry/ouro/components/ouro/progress/progress.test.tsx
verify:testExists(registry/ouro/components/ouro/slider/slider.test.tsx::Slider across locales > renders the same value text on a server in any locale, and shows the reader's once hydrated)verify:testExists(registry/ouro/components/ouro/progress/progress.test.tsx::Progress across locales > writes the value in en-US on a server in any locale, hydrates it as it is, and shows the reader's once hydrated) - declared: test/conformance/interactions.test.tsx
verify:fileLine(test/conformance/interactions.test.tsx, /focusExit declares/)verify:fileLine(test/conformance/hydration.test.tsx, /clientOnly/) - physical-allowed: scripts/source-rules.test.mjs
verify:testExists(scripts/source-rules.test.mjs::no component places, pads, borders or rounds a part by a physical side unless physicalAllowed says why, and every allowance is still used) - exemptions: scripts/conformance-exemptions.test.mjs; .github/workflows/conformance-exemptions.yml
verify:testExists(scripts/conformance-exemptions.test.mjs::a new exemption needs its reason and a mention in the description; one the base has needs neither)verify:testExists(scripts/conformance-exemptions.test.mjs::storyShapeProblems fails every conformance parameter it cannot read as a literal, so no exemption escapes the ratchet)verify:testExists(scripts/workflows.test.mjs::the conformance exemptions check runs on each pull request and its description's edits, and passes in the merge queue without checking) - mutation-run: scripts/mutation.test.mjs
verify:testExists(scripts/mutation.test.mjs::runtimeSource takes component and lib code, and leaves tests, stories, types and the test kit)verify:testExists(scripts/mutation.test.mjs::testPhases runs a mutant against its own file's tests, then its importers', and an unannounced file against every file's once)verify:testExists(scripts/mutation.test.mjs::testFilesFor runs a component file's folder's tests, and a lib file's own tests, then its importers') - mutation-survivors: scripts/mutation.test.mjs
verify:testExists(scripts/mutation.test.mjs::survivors are the mutants that survived or that no test reached, described by place, mutator and replacement) - mutation-disables: scripts/mutation.test.mjs
verify:testExists(scripts/mutation.test.mjs::disableProblems fails every Stryker comment a change adds to runtime source that is not next-line <Mutator>: <reason>) - mutation-ignored: scripts/mutation.test.mjs
verify:testExists(scripts/mutation.test.mjs::the ouro-classes ignorer leaves out only strings in class positions: className, cn() and cva() arguments, and *Class constants)verify:testExists(scripts/mutation.test.mjs::the ouro-classes ignorer leaves out the development check, and mutates the rest) - mutation-ci: .github/workflows/ci.yml
verify:testExists(scripts/workflows.test.mjs::the mutation shards mutate a pull request's changed runtime lines, split by file, start from the kept results, and report nothing to mutate otherwise)verify:testExists(scripts/workflows.test.mjs::conformance and mutation are each one check over their shards, which passes only when every shard passed) - mutation-plan: .github/workflows/ci.yml
verify:testExists(scripts/workflows.test.mjs::the mutation plan keys the kept results by pull request and dependency hash, and runs five shards without them, three with) - mutation-cost: scripts/mutation/cache.mjs
verify:testExists(scripts/mutation.test.mjs::shardUnits makes each changed range of runtime source a unit of its own, with its file's tests, so one large file's mutants can spread across shards)verify:testExists(scripts/mutation.test.mjs::planShards weighs each mutant by its own tests' measured time and each shard by its first run over every test file it needs)verify:testExists(scripts/mutation.test.mjs::parseTestSeconds reads each ouro-chromium test file's time from a CI log, colour codes and timestamps dropped) - mutation-split: scripts/mutation.mjs
verify:testExists(scripts/mutation.test.mjs::splitRange cuts a long range into parts of about the given mutants, only where no node runs past the cut) - mutation-cache: scripts/mutation/cache.mjs
verify:testExists(scripts/mutation.test.mjs::dependencyFiles takes every lib file and what the changed files import, through each other; dependencyHash changes with any of them)verify:testExists(scripts/mutation.test.mjs::mergeReports makes one incremental report of the shards', renaming each test by its file and name so killedBy still names it)verify:testExists(scripts/mutation.test.mjs::dependencyFiles also takes what decides an outcome beyond the source: the lockfile, the theme and app.css, the Vitest config and setup and what they import, the tests and what they import, but not the changed files themselves)verify:testExists(scripts/mutation.test.mjs::pruneChangedFiles drops the kept mutants of a file whose source changed, since Stryker would reuse a Killed on a line the change left alone)verify:testExists(scripts/mutation.test.mjs::pruneChangedFiles also drops a changed file's kept results when a changed file it imports, directly or through others, changed since: Stryker does not see an import change)verify:testExists(scripts/mutation.test.mjs::a shard's fresh Survived outlives another shard's carried-over Killed: each report is cut to its own ranges, and a merge keeps the undetected status)verify:testExists(scripts/workflows.test.mjs::the mutation job merges the shards' results and keeps them under the plan's key on a pull request, and never fails for the cache) - ratchets: scripts/kits/ratchets.test.mjs
verify:testExists(scripts/kits/ratchets.test.mjs::ouro-census: updateBaseline > accepts a run without a reason exactly when no state is dropped and no state's unmarked count rises)verify:testExists(scripts/kits/ratchets.test.mjs::ouro-source-rules: main against a base ref > fails exactly when a file gained buttons the base's baseline did not count, unless this pull request recorded why) - ratchet-runs: scripts/kits/ratchets.test.mjs
verify:fileLine(scripts/kits/ratchets.test.mjs, /const runs = \{ seed: 133, numRuns: 300 \}/)verify:fileLine(scripts/kits.test.mjs, /--experimental-test-coverage/) - ratchet-census: scripts/kits/ratchets.test.mjs
verify:testExists(scripts/kits/ratchets.test.mjs::ouro-census: updateBaseline > accepts a run without a reason exactly when no state is dropped and no state's unmarked count rises)verify:testExists(scripts/kits/ratchets.test.mjs::ouro-census: updateBaseline > records every rise and every drop it accepts, with its reason and the counts it moves between) - ratchet-source: scripts/kits/ratchets.test.mjs
verify:testExists(scripts/kits/ratchets.test.mjs::ouro-source-rules: findRegressions > pairs each move one to one, within a file or a component, and counts every other addition as a regression)verify:testExists(scripts/kits/ratchets.test.mjs::ouro-source-rules: main against a base ref > fails exactly when a file gained buttons the base's baseline did not count, unless this pull request recorded why)