Inspector Styles panel
Overview
The Styles panel adds tap-to-select element inspection and touch-friendly CSS editing to the Inspector. Tap an element in the live preview (or type a selector), then edit its inline styles as a plain list of property → value rows that write straight to the page — and read the result in a pinned, live element preview, without scrolling back to the Preview panel.
Usage
Open the Inspector tab, connect to a page, and turn on the Styles panel (its chip in the panel bar). Then:
Tap to select — tap Tap element to enter pick mode, then tap anywhere on the live preview. While pick mode is on, the preview shows a Pick: tap an element in the page banner with a Cancel button, and the tapped surface is outlined in the accent colour with a crosshair cursor — so a tap that will select an element is never mistaken for a tap that pokes the page. The matching element is highlighted (CDP
Overlay.highlightNode) and its label (div#hero.card), box-size, inline styles, and computed styles appear. The label and box size head the panel's own body (see Check the element), which is where the element's identity lives. Pick mode disarms itself after a successful pick; a tap that hits nothing selectable keeps it armed so you can try again, and Cancel (or the crosshair button in the Styles card header, which is the same toggle) turns it off without a trip back to the panel.
Alternatively, type a CSS selector (e.g.
#hero .card) in the small field and tap Select. This works even when the preview is hidden.
Check the element — the selected element's identity is the first row of the panel body: a full-width chip reading
div#hero.cardwith its box size at the end, which taps to copy the selector (the desktop Styles pane's Copy selector) and reports the result in the status pill above the panels. The row it shares with nothing is why it is readable: the three card-wide actions (Clear, Refresh, Pick) used to be three labelled buttons on that same line, which at a 360 px width left the label answering what am I editing? about 100 px — so it ellipsized first. Those three moved up into the card header, next to the panel's own eye (see The actions are in the card header below), and the identity kept the row. The chip goes away when the selection is cleared. Under it sits the element preview (a clipped screenshot of the selected element), and it is pinned: the identity row is sticky, so it stays on screen while the property list below scrolls and an edit's result is readable in place. The preview is live: it follows the page on its own (a style typed into the page, a field filled from the Preview panel's type bar, an animation, the page's own script), so what it shows is what is on the page. It never blanks between captures: a new capture is written into the same<img>node, and a browser keeps an image's last painted bitmap up while a newly assignedsrcdecodes — so selecting a different element goes straight from the old picture to the new one, instead of showing an empty strip for the ~250 ms a clipped capture and its decode take (longer on a real page). Tap the image to force a re-capture on demand; a caption under it reads Live element preview — tap to refresh and reports the capture's size.Walk the tree — the Element tree section opens with one line saying what it is for (Tap a chip to select that element — the highlighted chip is this one) and then has two labelled rows, so neither the direction of travel nor what a chip does is in doubt:
↑ Parents — the path from the root to the selected element (
html › body › main#app › div.card), with a chevron between the chips. The last chip is the current element: a static accent chip, not a tap target (and markedaria-currentfor screen readers). Tap any other chip to move up one or more levels — one tap instead of re-picking a possibly overlapping element on the live preview. When the path is wider than the panel a‹sits in the gap before it, saying the path continues; swipe the strip to reach the root.▸ ↓ 3 children — a disclosure whose visible text is the count spelled with its noun, opening one wrapped row of child chips (two to a line). Tap a chip to move down.
The ↑ Parents row is one line — label and path together — and the chip strip inside it scrolls sideways, auto-scrolled to its end so the current element (the last chip) is the one in view; a deep path therefore costs one 44 px row instead of the three or four lines of pills it used to wrap into above the property list. The children chips keep wrapping, because they are a collapsed-by-default disclosure read all at once rather than a path. Ancestors are capped at 8 and children at 6 per read, with the real child count shown on the toggle.
Change it with touch controls — the Style controls section edits the element with chips, sliders and swatches instead of typed values: six group tabs (Layout, Spacing, Size, Text, Colour, Effects), segmented chips for the enumerated properties (
display,align-items,justify-content,text-align,font-weight), sliders flanked by − / + steppers with a Fine / Coarse step switch and unit chips, a box model whose four margin and four padding edges are each tappable, and colour rows offering the colours this page already uses. It opens on Layout for a flex or grid container and on Spacing otherwise, shows only the controls that apply to the element (a Block element has noflex-directionrow), and writes the element's own inline style — one write per gesture, one receipt entry. Every row's value is also a button into the editor below. See Inspector touch controls.Edit a property — tap any row in Declared styles, edit the property and value, then tap Apply. The change is written to the element's inline style (
element.style.setProperty) and reflected immediately in the pinned preview. Apply keeps the sheet open so a value can be adjusted repeatedly; the close button reads Cancel until something has been applied, then Done. Tap Remove to drop a property. A declaration the browser will not accept is named before you apply it, and Cancel on a field you have edited but not applied asks before discarding it.
A declared row opens with its current value already in the field, so an existing style is edited rather than retyped — tap the row, change what you need, Apply. The label reads
Edit margin, value 40px.
Raise the priority when a rule keeps winning — the sheet's Priority control sits under the value and reads normal or !important. A normal inline declaration does not beat a stylesheet rule marked
!important; when the value you typed appears to do nothing, tap Priority until it reads !important and apply again. It opens on whatever the element already stores, so editing the value of an!importantdeclaration keeps its priority. A declared row that is stored!importantshows an!importantbadge next to the property name.
Removing a declaration that carried
!importantand then undoing that removal brings the priority back with the value — an undo never returns a declaration to the cascade in a weaker state than it was.
Find what you changed — every property edited in this session is hoisted to the top of both lists (most recently changed first) and highlighted with an accent bar, tinted background, and a
CHANGEDchip. The value shown is re-read from the page after each edit, so the highlighted row is current. Picking a different element clears the highlight; Refresh on the same element keeps it.Nudge numbers — for a numeric value (
24px,1.5,80%) the value field is flanked by − / + steppers that apply immediately, which is the fastest way to size something on a phone. The steppers are disabled for non-numeric values (colors, keywords, shorthands).Add a property — tap + Add property (the primary button under the controls) to open the property browser inside the panel, right under that button: search every property, narrow it by category (All / Layout / Spacing / Size / Type / Colour / Effects), or tap one of the suggested chips (color, background-color, font-size, margin, padding, border). Every card draws what the property does and reads Choose, or Edit with the value the element has now (its tooltip reads
padding — set to 12px 8px). Picking a card closes the browser and opens the value editor on that property: a property the element declares opens with its current value, and the others open on the family's neutral value (0pxfor a length,0for a number,0ms/0degfor a time or an angle) so the type switch, the unit row and the rail are all present from the first tap — see Inspector value types. You can also type any property name in the editor's property field. Because the browser is a section of this card rather than a sheet over it, the element's pinned preview, its identity and the controls already set stay on screen while you choose — and + Add property closes it again.Refresh — the refresh button in the card header re-reads the element's styles after an external change; Computed shows the resolved values regardless of where they originate, so you can confirm an edit took effect even when a class or rule overrides it. It is disabled while a read is already in flight and while nothing is selected, so it never claims to have re-read something it did not.
Narrow the computed list — the Computed section is every property the browser resolves (~400 rows). Type in the filter field to search property names and resolved values, then use the Show control — All / Declared / Changed — to switch between the complete read-out, only what this element declares in its own inline style, and only what you edited in this session. The Show label and the three chips are one control and wrap together: on a narrow screen they move to a second line as a unit, so the label is never left stranded at the right edge of the row above, pointing at nothing. Each chip names its meaning in its tooltip and accessible name, and the line under the search states the filter in force in words (
Showing 12 of 406 · declared here, or2 of 406 match “px” · all resolvedwhen a search is on), so a small count is never mistaken for a broken read. Rows page in steps of 60, and a page never ends in the middle of a property family: if the 60th row is abackground-*property, the page is extended past 60 to finish that family, sobackground-imageis on screen besidebackground-colorinstead of being the first row of the hidden page. Scrolling to the end of the rendered list pages the next 60 in on its own, so reading down never dead-ends at a hard stop, and the Show N more button at the list's foot reveals the next step from anywhere (its label always counts what is still hidden — it is measured from the cut actually taken, not from the nominal 60; once everything is shown it reads Collapse to the first N, where N is that same extended cut).See where a value comes from — Matched rules lists every rule that matches the element (and every rule it inherits from an ancestor), most specific first, each with its selector, its
@mediacondition when it has one, and a count of its declarations. Rules are collapsed to their header — tap one to open its declarations — and only the first (most specific, so usually the one being asked about) starts open. Tap a declaration to open the edit sheet pre-filled with that property and value, which copies it ontoelement.styleso you can change it. The whole section is collapsed by default — the button reads Show rules, not a bare Show — and each rule card is marked with its origin, with browser-default rules carrying a browser chip rather than the DevTools abbreviation UA. Because a dozen of them per element bury the rule the user came for, they are filtered out of the list and the row under it offers them back in words: Show 5 browser default rules, which becomes Hide 5 browser default rules while they are on.Clear — the ✕ button in the card header clears the selection, the element chip, the pinned preview, the tree strips, and the page highlight. Switch the panel off and on again and the chip comes back, because the panel re-adopts the element the Inspector retained — only an explicit ✕ drops it.
Behavior
Edits land on the element's inline style only. This is the same origin the desktop Styles pane calls
element.style; it is safest because it wins the cascade on the element and is fully reversible (tap Remove). Changing stylesheet rules (matched rules) needsCSS.setStyleTextsplus stylesheet source parsing and is out of scope for this pass — but see Matched rules below, which makes the values a stylesheet supplies visible and one tap away from being overridden on the element.Matched rules is read-only, and that is deliberate. It answers "which class put this value here?", which is the question that decides whether an override is fixable from this panel at all: a value coming from a class looks permanently uneditable until the rule supplying it is on screen. Tapping a declaration opens the same edit sheet as a declared row, pre-filled with that rule's property and value, so the value can be brought onto
element.styleand changed — the edit still lands on the element, never on the stylesheet.In the Computed list, only the rows changed in this session are tappable — and that is the point of the highlight. The list is a read-only read-out of ~400 rows on purpose: a 44 px tap target on every one of them is ~17 000 px of scrolling inside a ~350 px panel, which is what made the panel feel endless. A changed row is the exception, because it is the row the user is asking about: there are only ever a handful, they are hoisted to the top, and tapping one opens the value sheet pre-filled with the value the page reports now — the same editor a declared row opens, with the value-type switch, unit chips and rail. The row keeps the interactive 44 px height and its accent bar; the rest of the list stays flat.
The browser-defaults toggle appears only when there are browser-default rules to show, because its count comes from the same response the list is built from. On a target where only the in-page scan could answer that count is 0 — the scan cannot see the user-agent stylesheet — so the control is absent rather than offering an empty view, and the accurate path is attempted first (including the selector-path fallback) precisely so that stays rare. When every matched rule is a browser default, the list holds one line saying so and the toggle sits under it, so the section is never an empty panel with an unexplained control in its header.
The cascade is ordered for reading, not as Chrome sends it. CDP returns
matchedCSSRulesleast-specific first (cascade order); the panel renders it reversed so the winning rule is at the top, underelement.style— the same reading order as the desktop Styles pane. Inherited rules follow, one group per ancestor, nearest first, each captionedfrom <ancestor>, because Chrome's inheritance chain and the ancestor list from the tree read are both built by walking up and line up index for index.The property must be non-empty to apply or remove; the editor shows an inline error otherwise. An empty value is refused too:
style.setProperty(prop, '')removes the declaration, so a blank Apply would silently unset the property instead of doing nothing. Apply is disabled until a value is typed; dropping a property is the explicit Remove action.A declaration the CSSOM will not keep is reported, never silently dropped.
element.style.setProperty()does not throw on a value the engine does not understand — it discards it and returns — so an invalid write used to be reported as a success: the row appeared optimistically, a change was recorded, and then the post-write re-read came back without the property, leaving the row to vanish and an Undo entry for a change that never happened. Two checks close that gap. The sheet validates the field on every keystroke (CSS.supports), showing the reason under the field and disabling Apply, so a bad value is named before it is written. The write itself then reads the style back and comparescssText, because no client-side check is authoritative: this is the browser's own verdict, and it is also the only way to tell a rejected declaration from a shorthand (padding: 30pxis stored as its longhands, so reading the typed name back returns''on a write that fully succeeded). Only an accepted write records a receipt entry.A declaration can be written as
!important, and the panel shows when it is. The edit sheet carries a Priority control that reads normal or !important, sitting with the value because it is part of what the declaration is. It opens on whatever the element already stores for that property, so editing the value of an!importantdeclaration keeps its priority instead of quietly demoting it. A declared row whose property is stored!importantcarries an outlined!importantbadge — visually distinct from theCHANGEDchip, because one is about this session and the other is about the cascade and outlives it. This matters more than it sounds: an inline write cannot beat a stylesheet's!importantrule, and the failure is silent. Measured on a div with.x { color: rgb(1, 2, 3) !important }: after a plain inline write ofrgb(255, 0, 0)the element's ownstyle.getPropertyValue('color')returnsrgb(255, 0, 0)whilegetComputedStylestill reportsrgb(1, 2, 3)— so the panel read its write back as a success and the page never changed. With the priority set, the same write lands and the page computesrgb(0, 128, 0). The priority is read back off the element rather than echoed from what was sent, it travels with the receipt entry, and undo restores it with the value: reversing an edit that added priority puts back the non-priority declaration, and removing an!importantdeclaration offers an undo that brings the priority back.Cancel does not throw away typed work silently. The sheet stays open across several applies, so it tracks whether the field holds an edit that has not been applied. Cancel, the ✕, and a tap on the backdrop all route through the same guard: an unapplied edit raises a Discard this change? sheet (with Keep editing and Discard), while a sheet with nothing pending — including one whose last edit was already applied — closes immediately. The common case therefore gains no extra dialogue.
Re-editing an existing style never loses it: a declared row (and a chip for an already-declared property) opens with the current value, so the common case — tweak one value on a style that is already set — is a single edit + Apply.
The sheets are mounted at the document root, not inside the panel. The edit sheet (and the Inspector's detail / confirm / profiles sheets) renders through
sheetPortal—createPortal(…, document.body)— instead of as a child of the Styles panel. The panel body is a scroll container nested in the page's own scroller, and an overlay rendered inside it can be laid out and clipped against that scroller rather than the viewport: the sheet's header and its Close button end up above the visible panel with no way back to them, because only the sheet body scrolls. At the document root the backdrop covers the whole viewport and the header, the property field and the action row stay on screen. The Add property browser deliberately goes the other way — it is a section of this card, not a sheet, so the element being compared against stays visible while a property is chosen. See Inspector touch controls for the measurements.Applying
<prop>: <value>may add longhands instead of the shorthand you typed (browser CSSOM behaviour:margin: 40pxcan appear asmargin-top/right/bottom/left). Each longhand is listed and editable as its own row.The receipt is the way back, not just a log. Each row of the session receipt (which sits first in the panel's scroll flow) is a 44 px button: tapping the changed value reopens that property's editor pre-filled with the value the page now has, while the ↺ button beside it reverses the change. A removed property has nothing to edit, so its row stays plain text and offers only the undo — no disabled button in the middle of a strip.
Changed properties come first. Edits made in this session are tracked and hoisted to the top of both the Declared and Computed lists, most recently changed first; untouched rows keep their original order (alphabetical for Computed, with vendor-prefixed properties last). In Declared styles a grouped row (
border) ranks by whichever of its longhands was changed most recently. They are highlighted with an accent left bar, a tinted background, and aCHANGEDchip — the chip and anaria-labelprefix of "Changed." carry the state for users who cannot rely on colour. Tapping a highlighted row in the Computed list reopens that property's editor on the value the page reports now (the declared list's rows always did). The set is cleared when a different element is selected, kept across Refresh of the same element, and drops a property that was removed.A shorthand edit highlights every declaration it wrote, not the name that was typed. The rows both lists carry are the element's own declarations, and the CSSOM expands a shorthand as it lands:
padding: 30pxis stored aspadding-top/right/bottom/left,border: 1px solidbecomes twelve longhands, and neithergetComputedStylenorelement.styleever enumerates the shorthand itself. So the set is read back off the element after the post-edit read — the property the user edited first, then every declaration of the element's own style that the write created (writtenNames/writesPropertyin shorthand.js) — which is what makesmargin,paddingandborder(three of the properties the add-property sheet offers first) hoist and highlight at all. Nothing is guessed: a name the element does not carry is never marked, and a custom property is never expanded (--a-bis a different property from--a, not a longhand of it). Removing the property, or undoing it from the receipt strip, drops the whole group with it. That read is only as good as the list it reads, so the panel's own copy of the element's declarations is kept in step with the page after every write (the same copy supplies the value an undo restores) — otherwise a shorthand applied to an element that had no inline styles marked nothing at all, because the longhands it wrote were not in the copy the panel was still holding from the moment the element was picked.Rows reflect the page, not the typed text: after every apply/remove the panel re-reads the element's own properties and their resolved values in one round-trip. So the Declared list shows CSSOM-normalised values (
#ffe600reads back asrgb(255, 230, 0)) and — importantly for the hoisted Computed rows — a changed property shows its new resolved value rather than the value captured when the element was first selected.The pinned preview is a region capture, not the full-page screenshot: the element is scrolled into view (centred), its box is measured, and
Page.captureScreenshotis asked for a padded window (520 × 360 CSS px, at most 2× device scale) anchored at the element's own top-left corner. A small element (the common case) is captured at near 1:1 as its own box plus padding; a large one is bounded to that window instead of a whole-page smear scaled down to an unreadable size. Anchoring rather than centring is what makes the preview show the component: a centred window on anything taller than 360 px captured its middle band — no header, no first row, no edge — so the image could not be recognised as the element it claimed to show.The preview follows the page (it is not a snapshot). A capture of the selected element is taken every 800 ms while an element is selected, so a change that never went through this panel — text typed into a field, the page re-rendering itself, a stylesheet swap, an animation — shows up on its own instead of leaving a stale image that the user has to notice and refresh by hand. The pacing lives in liveShot.js and is deliberately conservative, because a preview must not lie and must not cost the page anything:
one capture per interval, and the next interval is measured from when the previous capture finished, so a slow capture can never be followed immediately by another;
never two at once (and, more strictly, never two anywhere on the target: see Screenshots are serialized per target below);
skipped while the app is in the background (
document.visibilityState === 'hidden'), where nothing on screen can change, and while a capture the user asked for is running — a pick, an edit, a tap on the image, the card header's Refresh — which owns the preview;a capture that throws, or that returns nothing, is a no-op: the loop keeps its schedule and the previous image stays on screen;
a capture whose PNG is byte-identical to the one on screen is dropped before it reaches the DOM, so an idle page costs no decode, no layout and no scroll write (the same unchanged-frame drop the Preview panel uses);
the loop is keyed on the selected element and stopped when the selection is cleared or the panel goes away, so a hidden preview never keeps capturing.
Automatic ticks never touch the busy state: the caption does not flash Updating… and the tap-to-refresh button is not disabled under the user's finger because an automatic capture happens to be running. Only a capture the user asked for does that.
A live capture does not scroll the page. The manual paths centre the element first (so a below-the-fold element is rendered into its box), but a capture that runs while the user reads and edits must not drag the page around under them. A live capture therefore measures the element where it is (
captureElementShot(objectId, { scroll: false })) and clips in document coordinates — the region captures correctly whether or not it is inside the viewport (verified against a real Chrome target). On a target that forces viewport-only captures (a PDF viewer) a live capture is refused outright rather than returning the wrong pixels; the manual routes keep their centring and still work there, and the previous image stays on screen until one of them runs.A clipped capture leaves the page resized, so the viewport is put back. A
Page.captureScreenshotthat carries acliprenders by sizing the inspected page to the clip rect, and Chrome does not always put it back: measured in the running app, the inspected page's owninnerHeightalternated 295 ↔ 397 — the heights of the two different clips the Inspector asks for — about twice a second while the previews ran, and the page received realresizeevents. Each loop was therefore photographing the page at the other loop's size, so both previews alternated between two pictures. The Inspector always knows the viewport it wants (the Size preset the user chose, or native size for Auto), so every clipped capture is followed by re-asserting it — queued with the captures, so it can never land in the middle of one. Unclipped captures change nothing and pay nothing, and a target without anEmulationdomain still gets its capture (Emulationis unavailable on some targets; a failed restore must not fail the frame).Screenshots are serialized per target. Every capture in the Inspector — this panel's element shot and the Preview panel's full-page loop — goes through one FIFO queue (
queueCapture/runCapturein events.js), because two captures in flight at once on the same target interfere with each other. Measured on a static page with a fixed clip: the two loops running alone each return a byte-identical frame (1 distinct payload), while with overlapping sends the frame comes back as two different pictures (2 distinct payloads) — which the user sees as the preview blinking. The two panels cannot coordinate (neither knows the other exists), so the ordering is enforced where every capture already converges. The queue is a strict FIFO with no coalescing: a superseded live tick is dropped by its own caller, which is the only place that knows whether the frame is still wanted; a rejected capture does not poison the ones behind it; and the queue is per connection, so a reconnect starts clean.The image is the whole capture, at the strip's width (
height: auto,object-fit: contain), with amax-heightcap so a tall element cannot take the panel over. It used to be a fixed 42 px strip withobject-fit: cover, which cropped a 480 px-wide capture to its middle band again — the same problem one layer up — and that is why the pinned preview read as "the preview never shows the component". The button keeps a 44 px floor (min-height: var(--tap)), so a one-line element is still a full tap target, and a capped capture is letterboxed rather than cropped. An element with no layout (display:none, detached, zero-size) produces no preview rather than a broken image, and a failed capture leaves the previous preview in place instead of blanking the panel.Clear stays cleared. The ✕ empties the panel — selection, pinned preview, tree strips, rules, and the page highlight — and it stays empty. That took an explicit mark: the panel re-reads the element the Inspector retained whenever it has no model of its own, which is exactly the state Clear leaves it in, so the element used to come back one round-trip later and the button read as broken. The retained id is now recorded as handled before the model is dropped, and a panel that has just been switched back on still adopts (that path starts with no mark).
Tree navigation reuses the pick path. Stepping to an ancestor or a child resolves the neighbouring element to a RemoteObject
objectIdand then runs the samebuildNodeModelas a fresh pick, so a stepped-to element gets the same label, box size, style lists, page highlight, pinned preview, and changed-set reset as one selected on the preview. The breadcrumb is a single line that is auto-scrolled to its end (the current element) on every selection, so a deep path never hides the chip that answers "where am I"; a step that has nowhere to go (past<html>, or a child that was removed) reports a friendly message on the panel instead of failing silently.Tree reads are serialized, not exclusive.
readElementTreeis onereturnByValueround-trip and is guarded by the same "newest read wins" serial counter as the pinned preview (treeSerialvsshotSerial), so a slow read for a previously selected element can never overwrite the current element's breadcrumb. A target that cannot answer leaves the strip hidden rather than rendering a broken row.Matched rules has two sources.
CSS.getMatchedStylesForNodeis the accurate one (shadow DOM, adopted stylesheets, real@mediaapplicability, browser-default rules) and is tried first. Getting a nodeId for it is harder than the protocol suggests:DOM.requestNodeanswersnodeId: 0for a Runtime-resolved object on a number of targets — verified on the fixture this section was built against — which silently degrades every CSS-domain read. SoresolveNodeIdtriesDOM.requestNodeand then builds a unique CSS selector path in-page (stopping at the first unique#id, adding:nth-of-type(n)only where a same-tag sibling exists, capped at 32 parts) and resolves that throughDOM.getDocument+DOM.querySelector. Only when both fail does the panel fall back to scanningdocument.styleSheets(Runtime.callFunctionOn), which walks nested group rules (@media/@supports/@layer) keepingconditionTextas the media caption, skips cross-origin stylesheets that throw on.cssRules, and stops after 20 000 rules so a huge bundle cannot wedge the tab. Both sources emit the same shape, so there is one normalizer and the panel cannot tell which answered. The cascade read is guarded by its ownrulesSerial, so a slow answer for a previously selected element cannot replace the current element's rules.Density is a layout decision, not a styling one. The rules that keep the panel readable are recorded in the table above: read-only rows drop below the 44 px interactive minimum, interactive rows never do, collapsed-by-default is used for the sections that are answers rather than primary content (matched rules, and each rule within it), and the scroller hands the gesture back to the page at its end. Any of these can be relaxed back to a chattier layout, but the measured before/after column is the reason they are the way they are.
Shorthand expansions are folded away. Chrome reports a shorthand (
margin: 40px) as the longhands it expanded to, marking the generated entriesimplicit: true. Those are dropped in matched rules — four rows ofmargin-*would drown the rule that actually matters on a phone — whiledisableddeclarations are kept and struck through, because "this rule sets it, but the declaration is off" is invisible everywhere else. Each rule shows at most 24 declarations and the list at most 40 rules, both with a+Nline rather than a silent cut; the browser-default count in the toggle is taken before truncation so it stays honest.The Styles panel body is deliberately taller than the other panel bodies (
52dvh/ 320 px floor, vs 32 dvh elsewhere). A property row is a 44 px tap target and the pinned block above it is a fixed ~115 px that never scrolls, so the old 32 dvh scroller (218 px on a 360 × 680 phone) left room for exactly one row. That made the changed-first rows at the top of the list technically correct but effectively invisible. 52 dvh floors the list at ~4 visible rows on a small phone and 6 on a 390 × 844 one. When the panel grows (Preview hidden, ≥ 900 px) it is capped at78dvh: without the cap the scroller sized itself to its ~400 computed rows (a 19 000 px panel that pushed the rest of the inspector off the page) instead of scrolling.The panel preview is a strip as tall as the element it shows, capped so it never crowds the property list:
max-height: 132pxin the panel (220 px in the grown panel on a wide screen, 50 dvh in the edit sheet), with a 44 px floor on the button so a one-line element is still a tap target. A short element therefore costs only what its own aspect ratio needs, and a tall one is letterboxed inside the cap rather than cropped.The pinned chrome at the top is one 44 px row, and the preview scrolls with the flow. The identity chip and the element preview were once two halves of a single sticky block, and that block measured 178 px inside the panel's 353 px scroller at 360 px wide — half the visible height, permanently. So the group chip row, which is pinned to the same scroller, had nowhere to pin except
top: 0, i.e. straight on top of it: measured with a real target atscrollTop 1493, the chip row occupied245 → 354— the exact band the preview was drawing in — and the top 109 px of the property rows sat behind it, while the block's full 178 px stayed reserved underneath. A third of the panel showed two layers of chrome and no content. The preview is now the first row of the scroll flow (spanning the panel's 8 px padding so the identity, the capture and its caption read as one block until they scroll apart), and only the identity row pins — one 44 px tap target that keeps what am I editing? on screen for the whole panel. The chip row pins directly below it (top: calc(var(--insp-pin-h, 44px) - 6px)), where--insp-pin-his the identity row's measured height published as a custom property bypinnedStack.js— measured, not hard-coded, because the row grows an error line when a tree hop fails and its label wraps differently per element, and because the two strips are stacked by construction they may touch but must never overlap. The identity row paints above the chip row (z-index4 vs 3), so a chip row scrolling away with its own section passes under the opaque identity row rather than over it.The refresh hint is a caption under the capture, not a badge drawn on top of it. Overlaid, it sat on whatever the element rendered in that corner — on a page of body text it landed on the text — and a short strip of an element is exactly where the user is trying to read small type. The caption reads Live element preview — tap to refresh, shows the capture's pixel size on the right, and switches to Updating… while a capture the user asked for is in flight — the same-length word on the same line, so a refresh in flight does not move the rows below. An automatic capture leaves the caption alone, which is what keeps it from flickering once every 800 ms.
The empty state explains itself. It opens with one line saying what the panel is for, and once pick mode is on it says what to do next (Now tap the element in the live preview). When the Preview panel is hidden, pick mode cannot complete, so the Tap element button is disabled, carries a reason in its tooltip, and the state says The Preview panel is hidden — use a selector below — rather than arming an action whose tap would land nowhere.
Every row is a ≥ 44 px tap target (
--tap). Editing opens a bottom sheet with large inputs, the element preview, and prominent Apply / Remove actions — no hover-only or tiny inline-text affordances. The card header's icon buttons (clear ✕, refresh, pick-mode crosshair), the touch surface's group tabs, segments, steppers, unit chips, box-model edges and swatches, the value steppers, and the panel's eye/refresh/full-screen/type controls all use the full 44 px--tapsize so every control is a tappable target on a phone.The actions are in the card header; the identity is in the card body. Clear (✕), Refresh (↻) and Pick (crosshair) sit in the Styles card's own header row, next to the panel's eye and its full-screen button — the row that carries the panel's chrome, exactly where the Preview panel keeps its full-screen, refresh, type and eye buttons. They are glyph-only like every other control in that row, each with
aria-label+title, and each disabled when it cannot act (no element selected; a read already running; pick mode with the Preview panel hidden, because a pick needs the live preview to tap). The element's label and box size are the first row of the card body, where they have the whole width and cannot be crowded. The two are wired to the same panel: the header buttons call the handles the panel publishes (panelHandlesRef), so arming pick mode still drops the stale selection and disarming it still drops the page highlight, and the header reads the panel's published snapshot for "is anything selected" and "is a read running". The pick button's accessible name reads Stop picking — tap the preview to select while it is armed.Computed values are read-only and monospace. They wrap instead of being cut off: a computed row has no editor and no tooltip on touch, so an ellipsized
transition: opacity 200ms cub…was unreadable with no way to see the rest. A colour value also gets a 14 px swatch in front of its text, becausergb(255, 230, 0)is the same width as three other colours and says nothing on its own; the swatch is decorative and the value text stays the accessible content. This is not a guard against invalid values — the browser normalizes what it accepts (CSSOM ignores anything the engine rejects).The Computed list is filterable, because otherwise it is unusable. A typical element resolves ~400 properties, which on a phone is a multi-screen wall where the one property being hunted for is indistinguishable from the rest. The section bar carries a
visible/totalcount (e.g.12/412) and a three-way filter, and a search field sits under it:All — every computed property. This is the complete read-out and the default.
Declared — only properties this element declares itself: its inline style plus anything edited in this session. This is the "what is this element actually doing" view, since a computed wall is mostly inherited and default values. The name matches the Declared styles section above it, which lists exactly the same set.
Changed — only properties edited in this session. The "what did I just do" view.
The search matches property names and resolved values. Matching the value is the case that makes the field worth having: "which property holds
12px?" and "what is making this red?" (rgb(255, 0, 0)) are answered by typing the value, not the name. Matching is case-insensitive substring, with no fuzzy behaviour — a partial match that silently reorders a 400-row list is worse than no match. A clear button appears once there is text.The filter and the search compose (AND), and the count in the bar always shows the filtered number against the unfiltered total, so a filter can never silently hide rows.
Long lists are paged additively. One page is 60 rows, and the control at the end adds one page per tap — Show 60 more of 406, then Show 120 more, and so on, with Collapse to the first 60 to start over. Scrolling to the end of what is rendered adds the next page on its own (the panel's own
onScroll, one step per arrival at the end, ~72 px before the hard stop — about two rows, so the next page is on screen before the list runs out and reading the middle of the list never pages anything in); the button stays for jumping a page ahead from anywhere. It is deliberately not a single "show all": on a 360 px phone 406 rendered rows is ~11 000 px of scrolling in a 350 px panel, so one tap would turn the panel into a 30-screen scroll. The last step offers only what is left (Show 40 more), never a full page. Narrowing the query or changing the filter resets paging in the same update, so a stale expanded view can never render rows that were just filtered out.A page cut respects a property family, because the alphabetical order does not. The list is sorted by property name, and a cut at an arbitrary 60th row can land inside a family:
background-clip,background-color,background-image,background-origin… On a real 406-property element the nominal cut fell exactly afterbackground-color, sobackground-image— the property drawing the gradient the element was visibly rendering — was the first row of page two while its threebackground-*siblings all showed. The list read as though the property did not exist, and the only way to see it was to scroll past the end or type its name.pageEnd(computedFilter.js) extends the cut past the nominal page until the family it landed in is complete, capped at 24 extra rows so a pathological run cannot drag a page open (the bound is what paging actually promises). A cut already between families is left untouched, so ordinary pages stay 60 rows. TheShow N morelabel and theCollapse to the first Nbutton are both measured from the cut that was actually taken (moreAfter), never from the nominal 60 — a button offering 60 more while 61 are hidden is the same bug in a smaller place.An empty list says which empty it is: No property matches “…”, Nothing changed yet — edited properties appear here, Nothing set on this element yet, or No computed styles for this element. The three filter cases look identical on screen and mean three different things, and "nothing matches" in front of a filter the user forgot they set is the classic way a panel reads as broken.
Pick mode turns the preview tap from "click the page" into "select an element". It is controlled by the Styles panel and reflected on the preview.
Readability
The panel stacks five sections of similar-looking rows in one scroller, and on a phone the difference between "a list I can read" and "a wall" comes down to a few things. They were measured on a card written as style="margin-top:20px;border:1px solid #ddd" at 390 × 844.
Declared styles reads the way it was written. The CSSOM enumerates an inline style as longhands, so that one
borderdeclaration used to show as seventeen rows (border-top-width,border-right-width, … down to fiveborder-image-*: initialrows nobody typed) — 18 rows for two declarations. The list now folds the longhands back into the declarationsstyle.cssTextserialises, so it shows two rows:margin-top: 20pxandborder: 1px solid rgb(221, 221, 221). A shorthand row's tooltip names every longhand it stands for.Nothing is hidden by grouping. The longhand list stays authoritative: a longhand no declaration claims (a value written a moment ago, before the next read) is still listed on its own, and a serialised declaration with no matching longhand is dropped. The more specific shorthand claims first, so
border-radiuskeeps its four corners instead of disappearing insideborder.Edits work on the row as shown. Tapping
borderopens the editor onborderwith its full value. Apply writes the shorthand, the receipt records the shorthand it replaced (1px solid rgb(221, 221, 221) → 3px dashed red), and undo puts that shorthand back. A row is highlighted as changed when any of its longhands changed, so a box-model edit toborder-top-widthlights up theborderrow.The header says what and where.
DECLARED STYLES 2 · element.style: the count is of declarations as written, the unit the list renders, andelement.styleis the name DevTools gives an element's own inline style.
prop: value, with the colon. Every property row draws the colon a stylesheet uses between name and value. Before, the monospace name and value ran together with only a space between them. The colon is CSS-generated, so the row's text and accessible name are unchanged.Computed starts at
accent-color, not-webkit-border-horizontal-spacing. A plain sort puts the leading hyphen first, so Chrome's ~25-webkit-compatibility rows filled the whole first screen. Standard properties sort A–Z first, then vendor-prefixed ones A–Z (compareComputedinevents.js). Custom properties (--x) are not vendor-prefixed and sort with the standard ones.Computed values carry the contrast, not the names. The ~400 read-only rows used to set every property name in bold accent blue, so nothing in the list stood out. Names are now regular weight in the soft text colour and values are in the full text colour, which puts the contrast on the part being read. Changed rows keep the bold accent name, because those are the rows to find.
Sections are set apart. Each section after the first starts with a hairline rule and extra space, and the section headings are in the full text colour. Before, the tree, controls, declared, rules and computed sections had only an 8 px gap between them, and it was easy to scroll from one into the next without noticing.
The identity chip reads at a glance.
div#hero.cardis drawn in DevTools colours: the tag in the text colour,#idin the accent,.classesin amber. It is a size larger and vertically centred in its 44 px chip; baseline alignment had pinned it to the top edge, which looked like a missing second line. The chip's text, and the selector it copies, are unchanged.
Density and scrolling
A 360 × 680 phone gives the Styles panel about 350 px of scroller, and every pixel of fixed chrome comes straight out of the property rows. This is what the panel spends its height on, measured on a real element — <main id="app-container"> on a page with 15 matched rules and 406 computed properties:
| before | after | |
|---|---|---|
| Total scroller content | 15 182 px (43 screens) | 2 634 px (7.5 screens) |
| Pinned block (identity + preview) | 139 px | 115 px |
| Computed row (read-only) | 44 px | 24 px |
| Declared row (interactive) | 44 px, of which only 18 px tappable | 44 px, fully tappable |
| Matched-rule declaration row | 53 px (wrapped) | 44 px |
| Style controls (one group) | — (a chip row: 94 px) | 1 group at a time: 3-6 cards, each a chip row, a slider or the box model |
| Scroller, Styles as the only panel | 352 px | 488 px |
| Horizontal scrollers in the panel | 3 | 0 |
The pinned block carries the element's identity and the preview. The breadcrumb and child chips live in the scroll flow: they are a full tap target tall, and pinning them cost ~80 px of a 352 px scroller for navigation that is used deliberately and rarely, while reading values is the constant activity. The identity chip is pinned, so "what is selected" stays on screen at all times and while the block is scrolled.
The actions left the body row; the identity owns it. Clear / Refresh / Pick are three card-wide actions, so they belong in the card header — the row that already carries the panel's eye and full-screen button and, on the Preview card, the refresh / type controls — rather than in the body's first line. There they were three labelled buttons sharing a 360 px line with the identity chip, which left it about 100 px and made the one label that answers what am I editing? the first thing to ellipsize, in the row that also scrolls. As the body's own full-width row the chip carries
div#hero.cardand1131px × 272pxon one line — a name that fits, or ellipsizes only for atag#id.classlonger than the whole row, instead of always — and it taps to copy the selector — the desktop Styles pane's Copy selector — which is how the label leaves the inspector. Everything it shows is still published by this panel throughonSelectionChange, so the header buttons and the Intent surface read the same snapshot.A row that looks like a tap target must be one. The declared-styles row was a 44 px card whose button sat at its own text height, so only the middle 18 px of the row responded to a tap — the worst kind of target, because it looks correct. The button now stretches to the row's full height (
align-self: stretch) and the row is--tapplus its own 2 px of border so the button ends up at a true 44 px. The row deliberately carries no vertical padding, since padding shrinks the tappable area while the row still renders at full height. A test asserts both.Chip and breadcrumb labels clip on a child span.
text-overflow: ellipsisdoes not apply to the anonymous flex item a bare text child becomes, so a longtag#id.classlabel overflowed the chip's rounded border instead of ellipsizing. Each chip renders its label in a<span>that owns the clipping.Read-only rows are not tap targets. The 44 px minimum is the interactive target size; computed rows have no button and no editor, so sizing them at 44 px was pure waste that made a 400-row list 17 000 px long. They are 24 px in a flat list with hairline separators rather than a card each. Declared rows and matched-rule declarations keep the full 44 px because they open the editor.
The two directions are labelled. The parents row and the children disclosure used to render as one wrapped set of similar pills, which read as a single undifferentiated list: nothing said which chips went up and which went down, and the order of the parents had to be worked out from the labels. Each row now states its direction (
↑ Parents,↓ 3 children), the parents carry a›chevron between crumbs so they read as a path, and the children chips sit in their own row under their own label.The tree says what its rows do, and what its numbers are. Labelling the directions was not enough:
↑ Parents/▸ ↓ Children 1name two rows and a number without saying that every chip is a tap target, that the accent chip is already the selection, or what the1counted. Three changes close that. The section opens with a one-line hint (Tap a chip to select that element — the highlighted chip is this one) — the only place the panel explains itself, kept to a0.68remcaption so it costs one line of the scroller. The current chip carriesaria-current="true", so the visual accent and the accessible name agree rather than the screen-reader user hearing a list of equal buttons. And the count is a phrase (↓ 3 children,↓ 1 child) rather than aChildrenlabel followed by a bare badge: the collapsed row is the only thing on screen saying the element has children, and a heading plus a number makes the reader guess the unit. The phrase is the toggle's visible text and its accessible name, so there is one wording rather than two.A path that was scrolled says so. The Parents strip opens on its end (the current element), so a path wider than the panel opens on a chip the viewport edge cuts mid-word — measured on this repo's own fixture the leftmost visible chip read
late-optionsfordiv.translate-options, which reads as an element with that name rather than as the tail of a scrolled path. A‹in the gap between the label and the strip marks the hidden crumbs, and it is rendered only while the strip is away from its root (crumbsClipped, set by the same effect that scrolls the strip and kept in step by a scroll handler that writes state only when the flag flips). Two alternatives were rendered at 360 px and rejected: an overlay on the strip covered the very chip it was there to explain (…e-options), and a…beside the strip read as part of that chip's own label — a name starting with an ellipsis. A chevron is not a valid label character, so it cannot be misread, and it points the way the hidden crumbs lie.The child chips are disclosed, not shown. A wrapped row of children is 3-5 lines tall, and browsing children is rare next to reading values, so the chips are behind the
▸ ↓ 3 childrentoggle — a 44 px tap target that still states how many there are. Collapsed, the Element tree section is one hint line, one parents line and one children line; opened, the chips wrap two to a line (max-width: 46%) so a longtag#id.classellipsizes rather than taking a line each.The touch surface shows one group at a time. Six group tabs at the top of the section are the index of it: on a phone, six groups of cards stacked would be the same wall of rows the panel was already criticised for. A tab carries a dot when the element declares something in that group, and a group holds three to six controls — one screen rather than a scroll. Labels stay short for the same reason the old chip row wrapped: a card's label wraps rather than clipping, and the CSS property being written is always shown next to it in monospace.
The group chip row is pinned to the panel's scroller, not just brought into view. The chip row is the only index of the surface: while reading a group's cards, scrolling can otherwise push the chip row off the screen — at which point switching to a different group requires scrolling all the way back up first, on a touch surface that already covers most of the page. The reveal-on-tap JS only fires after a tap, so it can't help a chip row that's not on screen. The row is sticky inside the panel's scroller, pinned directly under the pinned identity row rather than over it (see The pinned chrome at the top is one 44 px row above), so navigation between groups is one tap away no matter how deep the user has read and nothing is ever painted over. The row spans the panel's 8 px padding (
margin: -8px, plus its own padding back inside) so chips read flush with the panel's edges.Switching group brings the new group into view, and the page always finds you. Switching group replaces every card below the chip row, and the panel's scroller keeps its offset across that swap — so reading the bottom of a long group and then tapping a shorter one would leave the entire new group above the viewport. Tapping a different chip scrolls its row to the top of the visible part of the panel (below the pinned element header); tapping the chip that's already on does not scroll. The same applies to walking the element tree: every hop re-reads the element, its tree and its rules, so the panel grows and any offset is stale — a hop from a node read at the bottom of the panel put the new element's identity, preview and controls above the viewport. A new selection scrolls the panel back to its top; re-reading the same element (a write, a Refresh, the live preview) leaves you where you were reading.
A failed tree hop says so where you are. The tree steps report failures through the panel's error line, and a failed hop happens precisely when you are scrolled deep in the panel. The error therefore renders inside the pinned block, with the element it is about, instead of below it and off screen — where an error read as a tap that did nothing.
Matched rules collapse to their headers. Rendered in full, the declarations of 15 matched rules were 9 000 px in a 350 px panel. Opening a rule is a 44 px header tap, and the header shows how many declarations it holds.
Nothing in the panel scrolls horizontally. The panel sits inside the page's vertical scroller, so a horizontal scroller in it is a trap: it hides its own content and steals the vertical gesture from a slightly diagonal swipe — which is exactly how a long list gets scrolled on a phone. The quick-add chips, the breadcrumb, the child chips and the touch surface's chip rows each briefly scrolled sideways or were at risk of it, and each wraps instead. The panel itself also sets
overflow-x: hidden, becauseoverflow-y: automakes the computedoverflow-xautoand would otherwise let a diagonal swipe drag the whole panel sideways even when its content fits. A test asserts no rule for these classes turns on horizontal scrolling.Values are never hidden. Property rows wrap a long value onto extra lines rather than truncating it, in both the Declared and the Computed list: a one-line ellipsis is the one thing a read-only row cannot afford. Rows grow a line or two — verified with
padding-bottom: max(120px,calc(env(safe-area-inset-bottom,0px) + 120px)), which reads complete in the list and arrives complete in the sheet.The panel's scroller hands the gesture back to the page. The property list is a scroller inside the page's scroller, and a swipe over it moves the list first; once the list is at its end the same swipe continues into the page underneath, which is the only way to get back to the ← Back link and the card headers above. The panel used to hold the gesture with
overscroll-behavior: containso that the view never jumped when a long list ran out — but the panel is52dvh, so at 360 × 667 it covers 308 px of the 564 px page view, and holding the gesture there made the whole top of the Inspector unreachable by touch: measured with the page scrolled down and ← Back 551 px above the viewport, three drag-down swipes over the panel left the page scroller atscrollTop 614, unmoved. Chaining (auto) moved the same three swipes614 → 439 → 264. The gesture now chains, and the "jump" it was avoiding is bounded by the shorter distance a list's tail has left to travel.A solo panel takes the viewport. When Styles is the only visible panel, its body is
72dvhinstead of the shared52dvh: nothing else wants the space, and the difference is ~20 visible rows instead of ~14 on a 360 × 680 phone.
Requirements
The DOM and CSS CDP domains must be enabled against the inspected target. The Inspector enables both on connect; if either is unavailable the panel shows "no element" rather than failing. The pinned preview additionally needs Page.captureScreenshot (already required by the Preview panel).
Related
Inspector touch controls — the chips, sliders, box model and property browser this panel now leads with.
Inspector — the host tab and its other panels.
Inspector target-origin model — which element, which rule, and where an edit lands. The panel publishes its selection from it.
Chrome Debug MCP — automating the browser from the assistant.