// layout — split from src/client/mobile.css.ts (2026-08-16), order preserved.
// Self-contained: the mobile media query opens and closes in this file.
export const LAYOUT_CSS = `/* ---------- mobile-only layout (narrow viewport AND touch-primary pointer) ---------- */
@media (max-width: 1023px) and (pointer: coarse) {
/* --- Phone chrome ---
The system status bar stays visible (no fullscreen). Three adjustments
make it behave:
- touch-action: pan-y pinch-zoom kills double-tap-to-zoom (and the 300ms
tap delay) while keeping vertical pan. Omitting pan-x (i.e. not using
the manipulation alias) forbids HORIZONTAL pan on the root: a
left-edge horizontal drag would otherwise be claimed by the browser as
a pan (firing pointercancel) before the sidebar swipe layer can
classify it. touch-action does not inherit and the behavior
intersection stops at the first scroll container, so only touches
landing directly on the root background are affected — inner
horizontal scrolling of content containers is untouched. pinch-zoom is
listed on purpose (#45): a bare pan-y also drops pinch, and then a
zoom the browser applied by itself — iOS enlarges the viewport when a
field under 16px takes focus — can no longer be undone by the user,
so the app stays magnified until it is reopened or rotated. Two-finger
zoom is also the WCAG 1.4.4 escape hatch and costs the gesture layer
nothing: pinch is not a horizontal pan.
- overscroll-behavior-x: none suppresses the browser's edge history
navigation on the root scroller — Android Chrome claims a horizontal
stroke that STARTS within its edge band (EDGE_WIDTH_DP=48dp,
NavigationHandler.java) and navigates BACK, the exact gesture that
opens the drawer ("页面直接返回上一页", 2026-08-29 user report). Only
html/body count for this (Chromium issue 41483088: inner containers
are ignored by the navigation path). iOS Safari's edge back-swipe has
no CSS opt-out (WebKit bug 240183) — there the widened gesture start
zone (START_ZONE_RATIO 0.45 of the viewport width, ~176px at 390px,
past every browser's edge-claim strip) is the mitigation.
- With the client's viewport-fit=cover, env(safe-area-inset-top) is the
status bar / notch height; the rules below push the app content below
it so the status bar never covers anything. Off notched phones (or in
a normal browser tab where the layout viewport already sits below the
status bar) the inset is 0 and nothing shifts. */
html,
body {
touch-action: pan-y pinch-zoom !important;
overscroll-behavior-x: none !important;
}
/* AppFrame: the drawer takes the sidebar column out of grid flow, so the
remaining in-flow items (center, details) land in tracks 1..2: give the
center every pixel and keep the details track at zero. The top padding
clears the status bar / notch for every in-flow surface (session header,
messages, composer); the absolutely-positioned drawer is unaffected (its
containing block is the frame's padding box, i.e. still the frame top).
box-sizing MUST be border-box: the official frame is height:100% of a
100%-height body, and it is content-box by default, so the safe-area
padding is ADDED on top of the full viewport height. The frame then grows
to 100% + inset, the document itself becomes scrollable by exactly the
inset, and the sticky composer seat (bottom:0 of the scroll body) lands
below the visual viewport. Symptoms on a notched phone: the whole UI can
be swiped up, the composer lifts off the bottom leaving a blank strip,
and the newest message sits under the composer because the host's
at-bottom follow scrolls its own scroll body, not the document. With
border-box the padding is taken out of the 100% height instead, so the
frame is exactly one viewport tall and the document never scrolls.
The leading html element selector is load-bearing, not decoration: the
third-party @linxin666/dsh-web-all sheet ships an equal-specificity
!important grid-template-columns for this same element under
(max-width: 768px), so without the extra element the winner is decided by
which sheet happens to be injected later. Measured before and after with
scripts/probes/cascade-conflict-probe.mjs: no computed value moves, the
rule only stops depending on sheet order (audit D-5 option A). */
html [data-mobile-nav="frame"] {
box-sizing: border-box !important;
position: relative !important;
grid-template-columns: minmax(0, 1fr) 0 0 !important;
padding-top: env(safe-area-inset-top, 0px) !important;
/* Bottom safe area, symmetric with the top inset above. It MUST stay in
this rule: box-sizing: border-box is the pair that makes the padding eat
into the one-viewport height instead of adding scrollable overflow
(pitfalls §safe-area — padding without border-box pushes the composer
seat below the fold).
Why we have to own it: the third-party @linxin666 sheet (the same one
the grid-template-columns note above is about) ships this padding only
under (max-width: 768px), and the host ships none at all, so 769-1023px
touch devices had no bottom inset from either side and their content sat
in the system-bar band (issue #193). In the <=768px band that sheet's
declaration and this one are the same property with the same value, so
they do not stack. */
padding-bottom: env(safe-area-inset-bottom, 0px) !important;
}
/* The sidebar column (first grid child) becomes a left drawer. The drawer
hugs the sidebar content exactly (the wide sidebar carries an inline
width, ~280px): a fixed 92vw box would leave a white strip where the
container background shows beside the content.
Closed state: translateX(-110%) — more than -100% of the max-content
width — guarantees the whole drawer (and its shadow, had it one) leaves
the viewport. A mere -100% leaves a sliver on screen; -105% (as used
before) left 14px of the drawer plus a long 32px-blur shadow gradient
visible along the left edge of the main UI. No box-shadow at all: the
dimmed backdrop already separates drawer from content. */
/* These legacy column rules stay armed on every host generation: the phone
owner prefers this drawer over the official overlay one (2026-09-13). The
host's own drawer ships NO full-screen backdrop, so the conversation beside
it stays hit-testable - the rejection reason.
Layering contract: this column is 1300 and the backdrop 1250 (base.css),
both deliberately above the host's native sidebarCol at 1100. The earlier
value of 40 sat BELOW that 1100: because this same rule also forces
position/inset/width on the element, the column kept a correct-looking box
while neither painting nor hit-testing, which is the "all black, click
anywhere closes" root cause. The backdrop we append carries the dimming. */
[data-mobile-nav="frame"] > :first-child {
position: absolute !important;
inset: 0 auto 0 0 !important;
/* !important is load-bearing: the host ships
[data-dsh-frame] [data-pane="sidebar"] { width: min(88vw, 320px) !important }
under (max-width: 768px), which at 390px resolves to a flat 320px and
BEATS a plain declaration here - measured: our max-content never applied
and the column stayed 320px.
280 is the drawer's hard floor, measured by sweeping the column width from
304 down to 264: the inner surface is a FIXED 280px box and never
reflows, so every pixel below 280 is simply clipped off its right edge
(the list stays 270px at every width and its right edge sits at 278, so
270 and below cut into the list itself). At exactly 280 the panel is fully
intact - only the 12px of its right-hand padding is given up - which is
what the owner asked for over the previous 304. Going narrower is a
one-line change, but it starts eating content. */
width: min(88vw, 280px) !important;
/* 1300 is a contract with base.css: the host pins its native sidebarCol at
z-index:1100 and paints its mid layers up to that band, so the drawer must
sit above the host stack AND above our own backdrop at 1250 (which dims
the content area). At 40 the backdrop covered the drawer itself, so
opening it showed a full-screen dim with no drawer (measured 2026-09-13
at 390px: backdrop [0,0,390,844] z1250 over column [0,0,320,844] z40, and
elementFromPoint(40,300) returned the backdrop). Keep in sync with the
backdrop z in base.css. */
z-index: 1300 !important;
transform: translateX(-110%);
transition: transform .28s var(--ds-ease-in-out, ease-in-out);
/* Keep the drawer's own content below the status bar / notch: the drawer
spans the full frame height (its absolute containing block is the
frame's padding box, so the frame's own safe-area padding does NOT
reach it). The drawer background paints the status-bar strip, which
the client's theme-color meta matches, so the strip reads seamless. */
padding-top: env(safe-area-inset-top, 0px) !important;
/* Kill the official sidebarCol right border: with the backdrop the edge
reads cleanly, and the settings dialog (width:100% of this box) stays
pixel-flush with the drawer. */
border-right: none !important;
/* The drawer's inner surface is 280px wide while the column is 88vw/320px, so
the remaining 40px showed our own column background as a vertical strip
along the right edge (measured: content right edge 280, column 320; the
owner reported a white bar). The inner surface owns that band instead, so
the strip is filled by the drawer's real surface colour.
That scenario cannot recur as the layout stands: the column is
min(88vw, 280px), never wider than the 280px inner surface, so the
inequality that produced the band (column > surface) no longer holds at any
viewport width (measured 430/390/340/320 = flush at 280, 300 -> 264,
280 -> 246; a narrower column just crops the surface). The only change that
would bring it back is raising the column's upper bound past 280px, or the
host narrowing the inner surface below it. */
/* The 40px band is a STACKING result, not a colour one: the drawer's inner
surface is only 280px wide (host markup), while our column is 320px and
carries z-index 1300 - so the column's own background paints OVER the
surface's right 40px. Pixel-verified from a screenshot with the drawer open:
x=10..270 rgb(249,250,251) (the surface) against x=285..315 rgb(255,255,255)
(our white column). Repainting the column with the surface's own value makes
the seam invisible.
The value pinned here is the host's own drawer-fill token - the one
.pI_x6G_sidebarCol paints with: --dsw-specific-sidebar-fill, i.e.
--dsw-static-neutral-bluish-50 (#f9fafb) in light and
--dsw-static-neutral-bluish-900 (#1b1b1c) in dark. Pinning it, rather than
letting the host rule paint, keeps the band covered without depending on
cascade order. The fallback is deliberately the token's LIGHT value: if the
host ever renames this internal token the declaration degrades to today's
light behaviour instead of going unset, so it cannot get worse. Do not
"simplify" this back to --dsw-alias-bg-surface: that name is defined nowhere
in the host, which froze this rule on its fallback and painted the drawer
light in the dark theme (issue #192). */
background: var(--dsw-specific-sidebar-fill, #f9fafb);
/* Drawer swipe gestures (edge swipe-in / content swipe-out, see
docs/specs/2026-08-27-sidebar-swipe-gestures.md).
One rule is load-bearing for the gesture layer: dropping pan-x on the
drawer lets horizontal pointermove events reach the gesture code —
WITHOUT it the browser treats a horizontal stroke as a pan, fires
pointercancel and the gesture never classifies (vertical panning stays
intact). Start-hit is decided purely by geometry on the document
capture listener (START_ZONE_RATIO = 0.45 of the viewport width, ~176px
at 390px); there is no hotspot element (removed per audit C2,
2026-08-27). pinch-zoom rides along with the
root value so a browser-applied zoom stays undoable inside the drawer
too (#45); touch-action intersects down the ancestor chain, so a bare
pan-y here would cancel the root's pinch permission. */
touch-action: pan-y pinch-zoom !important;
}
/* 壁纸兼容(issue #228):deepseek-harness-background 0.6.x 在
body[data-dsh-bg] 上把 --dsw-specific-sidebar-fill 置为 transparent(body 内联),
而抽屉列以该令牌为唯一底色;令牌存在时 var() 的 fallback 不会生效,整列
于是透出壁纸。宿主内表面(sidebarCol)用同一令牌,也透明,没有第二层
填充源。壁纸启用时按该令牌自身的两档静态值补回原色:浅色
--dsw-static-neutral-bluish-50、深色 --dsw-static-neutral-bluish-900。
这两个静态令牌在明暗两套里取值相同,所以深色必须单独一条。
规则只在 body[data-dsh-bg] 下命中,无壁纸时完全不参与。 */
/* @patch
id: wallpaper-drawer-fill
for: deepseek-harness-background 0.6.x 的 body[data-dsh-bg](壁纸启用时把 --dsw-specific-sidebar-fill 置为 transparent)
why: 抽屉列的唯一底色就是该令牌,第三方插件把它清空后整列透出壁纸;宿主内表面同样透明,没有其它填充源
until: 上游提供不透明的面板填充接缝,或不再把 --dsw-specific-sidebar-fill 置空——届时本块删除
test: tests/wallpaper-surface-fill.test.ts
*/
body[data-dsh-bg] [data-mobile-nav="frame"] > :first-child {
background: var(--dsw-static-neutral-bluish-50, #f9fafb);
}
body[data-ds-dark-theme][data-dsh-bg] [data-mobile-nav="frame"] > :first-child {
background: var(--dsw-static-neutral-bluish-900, #1b1b1c);
}
/* Closed slot, at the host's OWN specificity. 0.1.5 added a narrow-branch
rule [data-dsh-frame][data-sidebar-collapsed] [data-pane="sidebar"]
{ width:52px !important; transform:none; pointer-events:none;
background:transparent !important } - specificity (0,3,0), one class above
the rule above, so it won BOTH width and transform: the closed drawer
stayed a 52px transparent shell at x=0 and the only state delta left was
the width (52<->280), which "transition: transform" cannot animate.
Measured 2026-09-17: closed pane transform:none / width:52 /
rect [0,0,52,844], and every frame sampled across a toggle click stayed
transform:none - the owner's "no slide animation on click" report.
Matching that specificity (plus !important, since the host declaration is
important) restores the design's own slot (spec 2026-08-27, drawer DOM):
a min(88vw, 280px) column translated -110% of its own width, i.e. -308px
at 390px. The gesture layer never depended on this rule - it writes an
inline transform !important - so only the CSS-driven click paths regressed. */
[data-mobile-nav="frame"][data-sidebar-collapsed] > :first-child {
width: min(88vw, 280px) !important;
transform: translateX(-110%) !important;
}
/* Expanded state (frame without data-sidebar-collapsed) slides the drawer in.
The open state must be transform:none — NOT translateX(0): an identity
transform still makes the drawer the containing block for fixed-position
descendants (the settings dialog's .VOzbGW_overlay is portaled into the
sidebar DOM). With the identity transform the wide settings sheet
(100vw-16) overflows the 280px drawer, the dialog's focus scrolls the
overflow:hidden drawer to scrollLeft=102, and every static child (plus the
fixed overlay) shifts 102px off-screen. With transform:none the overlay is
viewport-anchored: it dims the full screen and the sheet sits at left:8. */
[data-mobile-nav="frame"]:not([data-sidebar-collapsed]) > :first-child {
transform: none !important;
}
/* The host's own drawer handle. It renders the branded fish glyph (a 24x17
path in a 23.16x17.04 viewBox) and the phone owner reads it as a stray
"whale" sitting at the very top-left of the header: measured [10,14,44,44]
against our own toggle at [8,12,28,28], i.e. the two overlap in the same
corner. It also duplicates what our toggle already does, so on the mobile
branch it is removed. The selector keys on the host's own label - the
element carries no distinguishing class (hHd-Xa_iconButton is shared with
every other icon button, and the label flips to "Collapse sidebar" when the
drawer is open, which is why the attribute prefix matches both states and
both get removed). Nothing in this plugin queries that element; the drawer
still opens from our toggle, the edge swipe, and closes by tapping the
backdrop or swiping it away. */
/* Two selectors and both are needed. The competitor is NOT the host's own
CSS: @linxin666/dsh-web-all injects, under (max-width: 768px),
[data-dsh-frame][data-sidebar-collapsed] [data-pane="sidebar"]
[data-dsh-responsive-part="sidebar-toggle"] { pointer-events: auto;
display: inline-flex !important }. That is 4 attribute selectors AND
!important - exactly what the first selector below is - so this is NOT an
out-specify, it is a TIE decided by sheet order, and it holds only because
our sheet is injected after theirs. Measured twice, not inferred:
scripts/probes/cascade-conflict-probe.mjs reports both sides imp=true at
(0,4,0) and lists this as a reviewed order-tie; if the injection order
flips, the dismiss shadow returns as a visible inline-flex box with
pointer-events restored. Done (audit D-5 option A, 2026-09-16): every
selector below carries a leading html, which lifts the first one to
(0,4,1) and ends the tie - the outcome no longer depends on which sheet is
injected later. The hash class and the label stay as fallbacks for hosts
without that hook. */
html [data-mobile-nav="frame"][data-sidebar-collapsed] [data-pane="sidebar"] [data-dsh-responsive-part="sidebar-toggle"],
html [data-mobile-nav="frame"] [data-dsh-responsive-part="sidebar-toggle"],
html [data-mobile-nav="frame"] [class*="hHd-Xa_toggle"]:is([aria-label*="sidebar" i], [aria-label*="侧边栏"]),
/* The label-only fallbacks MUST stay scoped to the seats the host's own
drawer handle can live in. Unscoped they match by aria-label substring,
and the session row's ⋯ carries 会话“
”的操作 — so any session
whose title contains 侧边栏 (or "sidebar") lost its ⋯ menu entirely
(2026-09-22 phone repro: title 侧边栏不见了 → rowActions button
display:none, row height unchanged, time shifted right by the 16px the
button would have taken). Anchor them to the frame's leading seat and to
the header's leading cell instead. */
html [data-mobile-nav="frame"] [data-conversation-header-leading] button[aria-label*="sidebar" i],
html [data-mobile-nav="frame"] [data-conversation-header-leading] button[aria-label*="侧边栏"],
html [data-mobile-nav="frame"] [data-shell-leading] button[aria-label*="sidebar" i],
html [data-mobile-nav="frame"] [data-shell-leading] button[aria-label*="侧边栏"] {
display: none !important;
}
/* The host's own right sidebar IS the Files panel on phones, and the host
pins it as a fixed full-bleed sheet: [data-sidebar-right-panel=fullscreen]
carries position:fixed; inset:0 and no inset of its own (the host CSS
never mentions safe-area at all). Its top row - the tab strip holding the
tab label, the + button and the Split / Exit-fullscreen pair at the right
edge - therefore sat UNDER the status bar: measured at 390x844 with the
panel open, the strip is [0,0,390,38] and the phone's status bar owns the
top of the screen. The frame's own safe-area padding cannot reach it: a
fixed element's containing block is the viewport, not the frame's padding
box. Taking the inset as padding keeps the panel's own --dsw-alias-bg-base
covering the whole viewport (no seam behind the status bar) and drops the
entire row below it, with the right-hand buttons still on the right edge.
ONLY the fullscreen form: the host's docked form (measured at 820x1180 -
form=push, position:absolute, 365px right-anchored) has the frame's
padding box as its containing block, so it already starts below the
status bar; padding it too would add the inset a second time. A host
generation that renames the form value should fail the probe loudly
instead of silently double-padding. The rule lives in the mobile branch,
so desktop keeps the host layout. */
[data-sidebar-right-panel="fullscreen"] {
padding-top: env(safe-area-inset-top, 0px) !important;
}
/* Phone chrome keeps only the collapse control. Split visibility must not
depend on data-dockkit-split-blocked: DockLayout measures after each commit,
so conditional display can alternate blocked/allowed at the width threshold.
Hide the wrapper too, since a blocked split wrapper is a tab stop. */
@media (max-width: 767px) and (pointer: coarse) {
[data-sidebar-right-panel] [data-dockkit-split-button],
[data-sidebar-right-panel] span:has(> [data-dockkit-split-button]),
[data-sidebar-right-panel] [data-sidebar-right-mode="push"] {
display: none !important;
}
}
/* prefers-reduced-motion: the drawer's .28s slide is motion; drop it
(audit S2 2026-08-27 — the old reduce block only covered the settings
sheet and its mask). Same idiom as the animation:none blocks below. */
@media (prefers-reduced-motion: reduce) {
[data-mobile-nav="frame"] > :first-child {
transition: none !important;
}
}
/* Drag handles are useless on touch and would float over the drawer. */
[data-side="sidebar"],
[data-side="details"] {
display: none !important;
}
/* --- Conversation text on mobile ---
The official message flow keeps desktop's 32px side gutters and 16px
type. On a phone: shrink the type a notch and widen the lines by
trimming the gutters (the sidebar drawer list keeps its size). The
flow's scroll container holds the markdown
paragraphs; since
DSH 0.1.2-rc.1 the composer is a Lexical contenteditable that also
renders real
paragraphs inside its own _scroll container, so the
composer must be excluded explicitly via
:not(:has([data-composer-input])). */
/* The official main scroll body reserves scrollbar-gutter for desktop
scrollbars (8px), which shoves every column off-center on a phone.
Classic desktop scrollbars (Edge/Chrome) also occupy ~8-17px in a
phone-sized viewport, shifting the column further. Mobile scrolling
is touch/wheel, so remove the scrollbar entirely on phones: the
column is then exactly centered in every browser. */
[data-phase] [class*="_scrollBody"] {
scrollbar-gutter: auto !important;
scrollbar-width: none;
}
[data-phase] [class*="_scrollBody"]::-webkit-scrollbar {
display: none !important;
width: 0;
height: 0;
}
/* ---------- selection-drag autoscroll ramp (2026-10-07 live-host probe) ----------
Report (Android): once the conversation chrome became unselectable, dragging
the selection handle up stopped grabbing the title bar, but the conversation
did not scroll WITH the finger - it teleported a few screens up instead.
Mechanism measured on the live host (390x844, touch-emulated, selection anchor
inside the message flow, pointer held 1.4s): Blink only starts autoscrolling a
selection drag once the pointer is within ~3px of the scrollport's top edge,
and the RATE grows with how far the pointer sits ABOVE that edge, saturating
fast - y=140/100 -> 0 px/s, y=70 -> 1018, y=50 -> 2225, y=30 -> 3329,
y=10 -> 3232 px/s. The conversation scrollport's top edge is the header's
bottom edge (y=67), so every finger position on the header is 17-57px "above
the list" and lands in the 2200-3300 px/s band: one 777px screen every ~0.23s,
which reads as dropped frames / a jump to somewhere else. The ramp is
geometric, not ours - from y>=100 nothing scrolls at all, so the speed is
decided by where the list's box starts.
Fix: extend the scrollport's box up under the header so a drag point over the
header is only a few px above the list edge. Measured with the box moved to
y=0: y=30 -> 0 px/s, y=10 -> 595 px/s. The visual position is unchanged by
construction - the box grows upward by H while padding-top: H pushes the
content back down, so an element at content offset X still paints at
H + X - scrollTop (live-host check: header [0,0,390,67] and the composer seat
[0,740,388,104] are identical before/after; only the box [0,67,388,777] ->
[0,0,388,844] and scrollHeight +H move).
H comes from --mobile-nav-header-h, written by the reconciler task
"header-metrics" (phone-chrome.ts) as the header's bottom edge measured from
the frame's top. It is 0px whenever the header is not measurable (hero / blank
header), which makes both declarations below a no-op - the layout fails open if
the host ever stops rendering a conversation header. Phone tier only: the
desktop header is an ordinary in-flow bar and there is no touch selection drag
to ramp. */
[data-mobile-nav="frame"] [data-phase] [class*="_scrollBody"] {
margin-top: calc(-1 * var(--mobile-nav-header-h, 0px)) !important;
padding-top: var(--mobile-nav-header-h, 0px) !important;
}
/* ---------- selection handle drag: keep the extent local (2026-10-07) ----------
The phone selection handle drag is a native (Android WebView
TouchSelectionController) gesture: each move hit-tests the handle position to
resolve the selection extent and then reveals that extent. With the bar
unselectable but still hit-testable it won that hit test, Blink walked FORWARD
in DOM order to the next selectable node - the flow's first item, the
"Load earlier" gate, measured on the live host - and revealed it: the viewport
teleported to the top of the conversation instead of extending by a line
(reported as "not scroll-selection, it jumps somewhere else"). Dropping the bar
out of the hit test for the duration of the selection lets the same point
resolve into the message the lifted box above now puts underneath it, so the
extent stays local and the scroll ramp stays gentle. 「pointer-events」 only -
no layout change, so nothing reflows while the finger is down. The marker is
set by installSelectionChromeYield (phone-chrome.ts) and only for selections
that live in the conversation (composer/drawer keep their chrome
interactive); header buttons remain reachable the moment the selection
collapses, which is also what a tap anywhere else does. */
html[data-mobile-nav-selecting] [data-mobile-nav="frame"] [data-phase] header.wSkVaW_header {
pointer-events: none !important;
}
/* A transparent header was harmless while nothing could scroll underneath it;
with the box lifted it would show the messages through the title row
(headless screenshot 2026-10-07: message text bled across "Standard mode"
and the tab strip), so the header rule below now carries an opaque
background. */
/* Message action rows (copy / run-time badges) can overflow the right
edge on narrow screens — keep them inside the message width. */
[data-phase] [class*="_actions"] {
overflow: hidden;
}
[data-phase] [class*="_actions"] [class*="_timeEnd"] {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap !important;
}
/* Message tooltip bubbles (copy / feedback labels, message-row hover
bubbles) are redundant on touch: the icon already flips to a checkmark.
Suppress only inside the actions row — the fork's original scope. On
this host (0.1.1-rc.2) NO tooltip renders as a visible bubble: the copy
label is a visuallyHidden span and no client-ui package emits
role="tooltip". The user message bubble (Sixlwa_bubble since the host moved
it to dsh-client-ui-chat) and the goal
bubble (oRe1gG_bubble) live in _userStack/_row, NOT in _actions — the
previously unscoped selector hid every user message on touch devices
(2026-09-06 live regression). role="tooltip" stays globally suppressed:
genuine ARIA tooltips are exactly what the sticky-residue fix targets,
and nothing legitimate carries the role today. The actions-row arm
re-activates by itself when a host version renders tooltip labels
inline in the actions row (DSH 0.1.2 shape). */
@media (hover: none), (pointer: coarse) {
[data-phase] [role="tooltip"],
[data-phase] [class*="_actions"] [class*="_bubble"] {
display: none !important;
visibility: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
}
}
[data-phase]
[class*="_scroll"]:not([class*="_scrollBody"]):not(:has([data-composer-input])):has(p) {
padding-left: 20px;
padding-right: 20px;
/* Message text follows the host's own font-size axis (Settings -> 字号大小)
instead of a frozen phone constant. The host writes the user's choice to
as --dsh-content-font-size and derives the longhand token
--dsw-font-markdown-base-font-size from it; the previous 15px !important
cut that chain at the container, so settings 12-17 did nothing for message
text while the host's own markdown blocks still moved — two sizes mixed in
one column (#52). Read the longhand token only: the other token ending in
-base is the font shorthand, an invalid font-size value that the parser
drops and the cascade silently falls back on. The fallback chain ends at
the host's own default axis value. */
font-size: var(--dsw-font-markdown-base-font-size, var(--dsh-content-font-size, 14px)) !important;
}
/* Descendants only inherit: the host already resolves the same token on its
own markdown blocks (and its styles pin 16px on paragraphs / list items),
so a rule per p / li / user-message text would cut the axis a second time. */
[data-phase]
[class*="_scroll"]:not([class*="_scrollBody"]):not(:has([data-composer-input])):has(p) p,
[data-phase]
[class*="_scroll"]:not([class*="_scrollBody"]):not(:has([data-composer-input])):has(p) li,
[data-phase]
[class*="_scroll"]:not([class*="_scrollBody"]):not(:has([data-composer-input])):has(p) [
class*="_text_"
] {
font-size: inherit !important;
}
/* Markdown tables: the official table uses width:max-content, so on a phone
it hugs the content and leaves dead space beside/inside the table. Force
the table to fill the message column and let the table wrapper handle
overflow if a cell is genuinely too wide. */
[data-phase] table {
width: 100%;
max-width: 100%;
}
[data-phase] th,
[data-phase] td {
max-width: none;
min-width: 0;
}
/* Markdown images: the official rule often forces width:100%, which
upscales small square images to the full message column. Show small
images at their intrinsic size; large / very wide images still scale
down to fit the column (max-width:100% keeps horizontal panoramas
adaptive without overflowing). */
[data-phase] [class*="_scroll"]:not([class*="_scrollBody"]) img {
width: auto !important;
max-width: 100% !important;
height: auto !important;
/* Cap square / tall images so a big sticker does not dominate the
narrow column; landscape images stay governed by max-width only.
The plain px line is the fallback for engines without dvh. */
max-height: 220px !important;
max-height: min(40dvh, 220px) !important;
}
/* User bubbles: the official stack is capped at min(525px, 82%), which on a
phone leaves a large blank strip on the left and pushes the bubble high.
On mobile let the user message fill the same full width as assistant
messages (the bubble background then spans the whole message column). */
[data-phase] [class*="_userStack"],
[data-phase] [class*="_userStack"] [class*="_bubble"] {
box-sizing: border-box;
width: fit-content;
max-width: 100%;
}
/* --- Composer bottom row on mobile ---
The official row contains two lanes: tools (plus + permission/mode
controls) and trailing (model + context + send). The previous rules made
the modes lane flex:none, so its full intrinsic width collided with the
model selector on narrow phones. Keep fixed hit targets fixed, but let
text-bearing controls shrink and ellipsize before they paint over the
trailing lane. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) {
box-sizing: border-box;
container-type: inline-size;
container-name: dsh-mobile-composer;
flex-wrap: nowrap;
/* 2026-09-23 店主:"每个功能键隔的空间太多"。真机实测间距主要不是 gap(6px)
而是各控件自己的内边距;这里 gap 收到 3px,配合下面模型 chip 的 padding
收紧,把右簇焊成一团。 */
gap: 3px;
padding-left: 6px;
padding-right: 6px;
/* The dropdown menu is absolutely positioned inside this row; any
overflow: hidden here would clip it. Inner lanes keep their own
overflow clipping, so the row itself can stay visible. */
overflow: visible;
}
/* Dual-primary form (subagent view: stop + send). The four-control
cluster [model][meter][stop][send] overflows the single-row lane the
nowrap rule above enforces; the model pill is the only shrinkable
item, so it collapses to zero and the fixed trio loses its auto
margin (all hug the lane's left edge, send may even paint off-view).
Restore the official wrap for this form only: the trailing lane
drops to a second full-width row where the four controls always
fit. Main-session three-control form keeps single-row layout. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]):has([class*="_primary"] ~ [class*="_primary"]) {
flex-wrap: wrap;
}
/* Issue #140: in that same dual-primary form the stop key and the send key
sit one 3px lane-gap apart — two same-shaped 34px pills where a mis-touch
on the left one interrupts the running reply. The 2026-09-23 「焊在一起」
3px decision keeps governing the main session's [model][send] cluster;
only this form (the one with two adjacent destructive-adjacent primaries)
gets +8px between the stop and the send key. Knob: margin-right. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]):has([class*="_primary"] ~ [class*="_primary"]) > [class*="_trailing"] > [class*="_primary"]:has(~ [class*="_primary"]) {
margin-right: 8px;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > :first-child {
flex: 0 1 auto;
min-width: 0;
/* 2026-09-23 店主第三轮:"左边那三个功能区挨得太近了,隔开一点点"。
权限控件收窄 16px 后,📎 跟着整体左移、贴到了 ⌄ 上(实测墨迹间距只剩 ~3px)。
工具道 gap 单列放宽到 8px(右簇仍 3px,保持焊在一起)。 */
gap: 8px;
/* The permission dropdown (Menu, side: top) pops upward from inside the
tools lane; overflow hidden here would crop it, same as the row. Text
ellipsis is handled by the trigger label itself. */
overflow: visible;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] {
flex: 1 1 auto;
min-width: 0;
gap: 3px;
/* Must not clip the model dropdown; the model trigger clips its own label. */
overflow: visible;
}
/* Permission / plan controls share the tools lane inside the a2-style
'div.modes' container (class survives as 'css.modes'; audit doc §10.1 /
E-1). The positional anchor '> :first-child > :nth-child(2)' was already
off-target on rc.2 and dies entirely on a2, so the series re-anchors on
the tools lane's modes container: '[class*="_tools"] > [class*="_modes"]'
(live-verified on the rc.2 host: the modes div is a direct child of the
tools lane, a grandchild of the row — a row-direct-child anchor matches
nothing on either generation). The permission label uses the remaining
tools width, while the lower-priority plan slot keeps an icon-sized
target instead of stealing model width. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_tools"] > [class*="_modes"] {
flex: 0 1 auto;
min-width: 0;
max-width: none;
/* 2026-09-23 店主:"左边那个权限的也缩一点点":容器 gap 4→0。 */
gap: 0;
/* The permission Menu list (side: top) pops upward out of this lane;
overflow hidden crops it. The trigger label clips its own text. */
overflow: visible;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_tools"] > [class*="_modes"] > [class*="_trigger"] {
flex: 1 1 auto;
min-width: 28px;
max-width: 100%;
display: flex !important;
overflow: hidden;
/* 权限 trigger 自带内边距 + flex gap(图标与 ⌄ 之间),图标化后都是浪费:
2026-09-23 按店主"缩一点点"归零(真机 44px 盒 → ~34px)。 */
padding: 0 !important;
gap: 0 !important;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_tools"] > [class*="_modes"] > [class*="_trigger"] > [class*="_triggerLabel"] {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap !important;
}
/* Slot wrappers such as the live plan chip are not trigger elements. Do
not force them into an icon-sized box: their child button would overflow
that wrapper and paint over PermissionSelect. Keep the wrapper intrinsic;
the model lane below is the one that sacrifices width. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_tools"] > [class*="_modes"] > :not([class*="_trigger"]) {
flex: 0 1 auto;
min-width: 34px;
max-width: max-content;
overflow: visible;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_tools"] > [class*="_modes"] > [class*="_wrap"] > [class*="_chip"] {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap !important;
}
@container dsh-mobile-composer (max-width: 359px) {
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_tools"] > [class*="_modes"] > [class*="_trigger"] > [class*="_triggerLabel"] {
display: none !important;
}
}
/* 权限触发器(宿主 dsh-client-ui-permission-presets,样式哈希 iWlSmW_)自身带
padding:0 4px 0 8px + gap:4px —— 与模型 chip 同款浪费(左 8px 是给文字留的)。
2026-09-23 店主:"左边那个权限的也缩一点点"。注意:它外面套了一层
display:contents 包装(真机探针:modes[55,44] > div[contents] > root[55,44]),
所以「_modes > _trigger」这类直接子代锚点命不中(上一版改了没反应),
必须用哈希后代锚点;哈希变了整条自动失效,不会误伤别家。 */
[data-mobile-nav="frame"] [data-phase] [class*="iWlSmW_trigger"] {
padding: 0 !important;
gap: 0 !important;
}
/* 2026-09-23 店主:"权限的图标有点小,稍微大一点点,不然左边轻右边重"。
宿主把图标包在 _triggerIcon 里、自己写死 14px(iWlSmW_triggerIcon svg
的 width/height 都是 14px),与 + / 📎 的 16px 不齐。只放大那个包装里的
svg:⌄ 箭头不在 _triggerIcon 内,不会被一起放大。盒子 28×28 不变(16 仍有余量)。
注意:本文件是模板字符串,注释里**不能出现反引号**(会劈开 CSS)。 */
[data-mobile-nav="frame"] [data-phase] [class*="iWlSmW_triggerIcon"] svg {
width: 16px !important;
height: 16px !important;
}
/* Model selector: flexible and shrinkable, but never clipped.
The root must be overflow:visible so the dropdown menu can render.
The trigger itself clips the label text. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_root"]:has(> [class*="_trigger"][aria-haspopup="menu"]) {
flex: 0 1 auto;
min-width: 0;
overflow: visible;
}
@container dsh-mobile-composer (max-width: 359px) {
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_root"]:has(> [class*="_trigger"][aria-haspopup="menu"]) {
flex-basis: auto;
}
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_root"]:has(> [class*="_trigger"][aria-haspopup="menu"]) > [class*="_trigger"] {
display: flex !important;
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_root"]:has(> [class*="_trigger"][aria-haspopup="menu"]) > [class*="_trigger"] > [class*="_triggerLabel"] {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap !important;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_root"]:has(> [class*="_trigger"]):not(:has(> [class*="_trigger"][aria-haspopup="menu"])) {
flex: 0 0 auto;
}
/* Model switcher menu: was centered here with left:50% + translateX(-50%), but the
host now PORTALS the menu to (measured 2026-09-23: _7KE1Ra_menu, role=menu,
position:fixed, parent=BODY, inline left/top), so this child-chain selector stopped
matching and the rule had been dead. The re-anchor lives in JS instead —
effects/model-menu-anchor.ts centers the panel on the trigger and clamps it to the
viewport. Do not re-add a CSS rule here without checking the portal parent. */
/* Phone reasoning-effort list is compact; the model catalog and the tablet stay
host-sized. Measured on the real host bundle (dsh-client-ui-model-selection
0.2.0-rc.2, 393x844, phone emulation): the effort pane renders 248px wide
(its own min-width is 240px) while its four levels need far less.
The two panes differ in the SHAPE of their radio list, and that difference is
the only reliable discriminator (measured, same run):
effort pane DIV._surface_ri079_1._7KE1Ra_menu[role=menu]
> DIV._material_ri079_21
> button._7KE1Ra_option[role=menuitemradio] <- DIRECT child
model pane DIV._surface_ri079_1._7KE1Ra_menu[role=group]
> DIV._material_ri079_21
> DIV._7KE1Ra_groups.scrollable[role=menu]
> SECTION._group_121p8_1[role=group] > button[role=menuitemradio]
The host sets 「role: pane === "model" ? "group" : "menu"」, so the effort pane is
the role=menu shell and its radios ARE direct children, while the catalog's sit
two levels deeper. Requiring both halves — role=menu AND a direct-child radio —
therefore selects the effort list and leaves the catalog at its host size.
Neither half is safe alone: dropping the role also matches the catalog's inner
_groups container, and matching radios as descendants selects the catalog shell.
(Measured with each variant: the group/descendant form hit the catalog only.)
「min-width: 0 !important」 is load-bearing: without it the host's own
min-width 240px keeps the pane at 240 no matter what width is set. */
@media (max-width: 767px) and (pointer: coarse) {
[class*="_7KE1Ra_menu"][role="menu"]:has(> [role="menuitemradio"]) {
width: min(208px, calc(100vw - 32px)) !important;
min-width: 0 !important;
max-width: calc(100vw - 32px) !important;
}
}
/* --- Fix composer row overflow at narrow widths (320px-360px) ---
Force every direct child of the tools and trailing lanes to shrink,
so they can fit within the available space without causing horizontal
overflow. The fixed-size icon buttons are exempt: officially both are
flex:none at a fixed size (plus 28x28, send 34x34) and must stay put,
not participate in adaptation. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > :first-child > :not([class*="_add"]) {
flex-shrink: 1;
min-width: 0;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] > :not([class*="_primary"]) {
flex-shrink: 1;
min-width: 0;
}
/* Pin the plus button at the left edge of the tools lane: official
flex:none 28x28, never squeezed by narrower viewports. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > :first-child > [class*="_add"] {
flex: none;
}
/* The context meter in the trailing lane is another fixed-size icon
control: its trigger is officially width:28px flex:none, but the root
itself is shrinkable, so a squeezed root lets the trigger paint over
the pinned send button. Keep the whole meter at its natural size; its
trigger uses aria-haspopup="dialog", so the model-selector menu rules
(keyed on "menu") still do not apply.
GENERATION NOTE (issue #140, 2026-09-29): 0.1.7-rc.2 moved the
ContextMeter out of this lane into the dock row under the card (next to
the TPS stats pills) — on rc.2 NONE of the trailing-lane meter rules in
this section match any more, and the trigger is back to its official
~22px-tall box. They stay for the 0.1.5/0.1.6 generations where the
meter really rendered in the lane (structural anchors, inert elsewhere);
the rc.2+ hit-area repair lives in the dock-row section below. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] > [class*="_root"] {
flex: none;
min-width: 0;
}
/* --- 右簇贴右:2026-09-23 重新对锚(模型胶囊改"只留图标"后暴露的旧账)---
宿主把右簇(模型座位 standardControls / 麦克风 activity / 发送 primary)
放进可增长的 trailing 车道,靠"某个成员带 margin-left:auto"把整簇顶到右缘。
插件原来把吸收器挂在模型 root 上:
> [class*="_trailing"] [class*="_root"]:has(> [class*="_trigger"][aria-haspopup="menu"])
但 0.1.7 的祖先链变成了
trailing > standardControls(flex item) > div[display:contents] > _root > _trigger
于是 root 只是 standardControls **内部**的 flex item,auto 外边距落在一个
内容宽度的盒子里 ⇒ 等于失效。真机探针实测三者 ml 全 = 0px,就是铁证。
2026-09-23 之前胶囊很宽、把车道填满,看不出来;胶囊一收成图标,右簇立刻
塌到左边(发送 x≈316 → 224,右边空出 ~76px,店主一眼看出"位置被移了")。
修法:把吸收器改锚到「车道的第一个 flex item」,并只在模型座位在场时生效
—— 那时宿主那条把 primary 的 auto 清零的规则也在生效,避免两个 auto 平分
空隙;模型不在场(子代理视图)照旧由 primary 自己的 auto 收尾。
justify-content: flex-end 是兜底:万一首个 child 是 display:none,auto
无处可挂时仍能贴右(此时无 auto 外边距,flex-end 才起作用)。 */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"]:has([class*="_trigger"][aria-haspopup="menu"]) {
justify-content: flex-end;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"]:has([class*="_trigger"][aria-haspopup="menu"]) > :first-child {
margin-left: auto;
}
/* ContextMeter (JObwrW_ hash family) hugging the primary key. This single
value is the whole spacing knob, and because the trigger box is centred on
the ring ink it doubles as the ink offset:
6px + margin-right = the sliver before the primary key = the ink's
leftward shift. 0px (current) therefore shifts the ink 6px -- exactly the
official lane gap, with no negative-margin trick left in the chain --
while -6px pins the ink perfectly still and +8px was vetoed on
2026-09-17 as "too much" (14px). The phone owner asked for a visible
shift after 1px (-5px) proved imperceptible, and will re-tune this number
by eye: change it and nothing else moves.
Anchor on the unique aria-haspopup="dialog" trigger (no other composer
control uses it), not the hashed class, so an upstream hash bump cannot
silently unhook us. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] > [class*="_root"]:has(> [class*="_trigger"][aria-haspopup="dialog"]) {
margin-right: 0px;
}
/* The model pill joins the same right cluster: its margin-left:auto absorbs
ALL trailing slack, so the adaptive void sits between the tools lane and
the pill (visible on wide phones/tablets), while [pill][meter][send] stay
welded together at the right edge on every width. Descendant combinator
on purpose: the pill root sits behind a display:contents wrapper, so a
direct-child combinator silently misses (probe-verified). Within the
trailing lane aria-haspopup="menu" belongs to the model trigger alone. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] [class*="_root"]:has(> [class*="_trigger"][aria-haspopup="menu"]) {
margin-left: auto;
margin-right: -4px;
}
/* Grow only the invisible trigger BOX, never the ring ink: 24x24 -> 28x34.
The WIDTH is capped at 28 by pure geometry, not by taste: the box is
centred on the ink, and the primary key's hit box begins 14px right of the
ink's centre, so 28 is the widest box that can reach that boundary without
stealing a single pixel from the destructive key (the current 1px sliver
is the spacing knob on the root rule above); the same arithmetic puts the
left edge on the model pill's edge. The 34px HEIGHT is free: the primary
key is already the tallest control in the lane, so the box cannot overlap
anything vertically and the row height does not move. Hit area 576 -> 952
square px (+65%) with the ink within 1px of its old spot (probe-asserted),
and the ring's ink stays at its official 14px -- enlarging it is rejected
as attention-grabbing. Knob: height can drop to 28 if the tap halo should
be a circle rather than a stadium. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] > [class*="_root"]:has(> [class*="_trigger"][aria-haspopup="dialog"]) > [class*="_trigger"] {
width: 28px;
height: 34px;
padding: 0;
}
/* Slack-absorber priority in the trailing lane: model pill > meter > send.
Exactly one element carries margin-left:auto so the adaptive void always
sits BEFORE the welded right cluster, never inside it. The meter itself
never had an auto before 2026-09-06: in subagent sessions the model seat
is officially absent (the parent pins the model), and zeroing the send's
auto on the meter's aria-haspopup="dialog" then left NOTHING to absorb
slack -- the whole right cluster hugged the lane's left edge (user
screenshot). Fix: when no model pill renders, the meter root becomes the
absorber, welding [meter][send] at the right edge like the main view's
[pill][meter][send]; the send's auto only survives when neither renders. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"]:not(:has([class*="_trigger"][aria-haspopup="menu"])) > [class*="_root"]:has(> [class*="_trigger"][aria-haspopup="dialog"]) {
margin-left: auto;
}
/* --- rc.2+ 的第二个失效面:子代理 composer 里环锚点回不到发送键左边(2026-10-12)---
上面那条「无模型胶囊时由 ContextMeter 当吸收器」的兜底,在 **0.1.7-rc.2+** 上
再也匹配不到:宿主已经把 ContextMeter 从 trailing 车道搬进卡片下方的 dock 行
(实测路径 … > [class*="_card"] > [class*="_root"] > [class*="_dock"] > span.JObwrW_root),
它不再是车道的子元素,「:has(> [class*=\"_trigger\"][aria-haspopup=\"dialog\"])」落空。
后果(headless 393×844 实测,主/子两视图同机对比):子代理会话里模型胶囊整个不渲染,
「standardControls」塌成 0×0 且 margin-left:0,于是车道里唯一带 auto 的是发送键自己,
插件注入的环锚点 span[data-mobile-nav=\"stats-ring-reserve\"](普通流内元素)被留在
车道左缘 —— 环 x=127,而主会话同一元素 x=310、发送键在 333:**188px 空档**,
用户看到的就是「上下文表跑到左边去了」。
修法与 #191 同源(同一根因族:rc.2 把 meter 搬走后,车道里的 auto 归属没人接管):
没有模型胶囊时让**环锚点**顶替 meter 当吸收器,并把发送键的 auto 清零 ——
不清零的话两个 auto 会平分空隙,环同样回不到键边。
门控与上面那条兜底完全一致(车道里没有 aria-haspopup="menu"),所以主会话
(模型胶囊在场)零改动。 */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"]:not(:has([class*="_trigger"][aria-haspopup="menu"])) > [data-mobile-nav="stats-ring-reserve"] {
/* !important 不可省:compat 层有一条 [data-mobile-nav="stats-ring-reserve"]
{ margin: 0 2px 0 0 !important }。两条都 important 时先比特异性 —— 本选择器
远高于那条裸属性选择器,加 !important 就能赢;不加则被它压成 margin-left:0。
2026-10-12 受控实验实测:不加 → 环仍停在 x=127、发送键反被拉到 150(188px 空档
只是从「环与键之间」搬到「键的右侧」);加 → 环 310 / 键 333,正是期望值。 */
margin-left: auto !important;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"]:not(:has([class*="_trigger"][aria-haspopup="menu"])) > [class*="_primary"] {
margin-left: 0;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] > [class*="_primary"] {
flex: none;
margin-left: auto;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"]:has([class*="_trigger"][aria-haspopup="menu"], > [class*="_root"] > [class*="_trigger"][aria-haspopup="dialog"]) > [class*="_primary"] {
margin-left: 0;
}
/* Issue #191 — the subagent running form (stop + send, both _primary) has
NEITHER of the zeroing rule's two gates: ModelSelect renders nothing in an
addressed subagent session (no aria-haspopup="menu" in the lane) and the
meter lives in the card's dock row since rc.2 (no _root > dialog trigger
in the lane either). Both primaries therefore keep the auto margin from the
rule above, and flex SPLITS the free space between the two auto margins —
the stop key lands mid-lane with a void on both sides of it.
Same recipe as the model-present case (:707-712): zero every auto so no
element competes for the slack, and let justify-content pin the whole
cluster right. Zeroing alone would hug the LEFT edge instead, so both
halves are required. Scoped to the dual-primary form, so the main
session's single-primary path keeps its auto untouched. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]):has([class*="_primary"] ~ [class*="_primary"]) > [class*="_trailing"] {
justify-content: flex-end;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]):has([class*="_primary"] ~ [class*="_primary"]) > [class*="_trailing"] > [class*="_primary"] {
margin-left: 0;
}
/* 2026-10-20(店主):「暂停键和发送键……大小缩小,和其他功能键保持一样的大小」。
同行三个功能键(+ / 权限 / 📎)的官方标称是 28×28,主键(发送、停止生成)官方
是 34×34 —— 手机档把主键压到同一档:盒 28×28、内边距清零、圆角保持正圆,
图标仍是 16px,因此圆心与同行按钮落在同一条线上。双主键形态(停止 + 发送)
同样命中,两者都收小,8px 的防误触间距规则不受影响。
2026-10-20 二轮:App 会往页面注入一张读不到 cssRules 的样式表(见上方页签条
注释),第一版没加 !important,真机上尺寸没变。这里按「抗覆盖」写:三项尺寸
全部 important,并补一条不依赖 [data-phase] / _card / _row 祖先链的兜底选择器
(车道直系子元素即主键),祖先链任一新代断层都不至于整条规则死掉。
后续:2026-10-20 四/五轮把**手机档**再改到 30px + 18px 图标并统一颜色,
覆盖在文件末尾「输入区功能键尺寸」段;本段仍是 768–1023 平板档的值,别删。 */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] > [class*="_primary"] {
box-sizing: border-box !important;
width: 28px !important;
min-width: 28px !important;
height: 28px !important;
min-height: 28px !important;
padding: 0 !important;
border-radius: 50% !important;
}
[class*="_trailing"] > [class*="_primary"] {
box-sizing: border-box !important;
width: 28px !important;
min-width: 28px !important;
height: 28px !important;
min-height: 28px !important;
padding: 0 !important;
border-radius: 50% !important;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] > [class*="_primary"] svg {
width: 16px !important;
height: 16px !important;
}
[class*="_trailing"] > [class*="_primary"] svg {
width: 16px !important;
height: 16px !important;
}
/* --- ContextMeter hit area on 0.1.7-rc.2+ (issue #140) ---
The stats-line overlay parks the ring in the composer card's trailing
lane (right cluster next to the send key — a deliberate 2026-09-23
placement, kept per the reporter's confirmation at acceptance). The
official trigger is 16x20px, far under the touch minimum. Regrow the
hit area IN PLACE with a transparent ::after (the 📎 recipe).
2026-09-29 acceptance, final geometry (headless-measured): the ring
svg and its reserve are set to 18px (compat.css.ts — 16px was too
small to aim, 24px/20px too big, the reporter settled on 18px), the
width growth is absorbed by the lane's left slack so the key gaps
stay 6px/5px, and the hit box is a SYMMETRIC 26x26 square
(inset -4px) keeping the generous touch area around the smaller
ring — 2px/1px clearance to the model and send keys. The track is
deepened to 25% black (compat.css.ts) so the donut reads as a
meter, not a spinner.
Knobs: the svg/reserve size (compat.css.ts) and this inset; the box
must stay a square hugging the ring, its edges clamped by the two
neighbouring keys. The dock container owns only this one dialog
trigger (the TPS stats pills render plain text), so a scoped
aria-haspopup="dialog" anchor cannot cross-match anything; the DOM
ancestry (ring inside the dock container) is unchanged by the
overlay's absolute positioning. */
[data-phase] [class*="_dock"] [class*="_trigger"][aria-haspopup="dialog"] {
position: relative;
}
[data-phase] [class*="_dock"] [class*="_trigger"][aria-haspopup="dialog"]::after {
content: '';
position: absolute;
inset: -4px;
}
/* --- Third-party model seats (issue #60: @hytime/dsh-thinking-effort) ---
A seat registered on conversation.input.model replaces the official pill,
so the trailing lane no longer contains an aria-haspopup="menu" trigger:
the pill absorber rule above never matches, and the meter fallback below
would split the slack with the seat (two auto margins share it), leaving
the seat stranded mid-lane. Worse, in the seat's open state the root's
only child is the absolutely positioned panel, so the root collapses to
zero width and the panel's right:0 anchor (width min(336px, 100vw - 32px))
sweeps 336px leftward from wherever the stranded root sits — 230px off
screen at 393px (reporter-measured: root x=106, panel left=-230; with our
stylesheet disabled the root sat at x=339 and the panel at +3, which pins
the blame on our injection). Both repairs anchor on the plugin's own
stable data-seat-* markers (identical across v0.2.3-v0.3.7) and leave the
official pill untouched:
1. the seat root stretches across the trailing lane with its content
pushed to the right edge, so the closed chip welds onto the
[meter][send] cluster AND the grown root consumes all free space,
which zeroes the meter fallback's margin-left:auto (flexible lengths
resolve before auto margins — no double void). The root is also made
position:static so the panel below anchors to the composer card
instead — the root is 0-width while the panel is open, so any
root-relative offset is meaningless;
2. while the panel is open its anchor is laid over the composer card
(left:0/right:0 against the card's padding box + an auto-margin
centre), so the panel is centred on the card, never rides the
collapsed root and never leaves the viewport.
── 2026-10-04, thinking-effort 0.3.7: the old centring recipe had to go ──
Repair 2 used to be 'left:50%; right:auto; transform:translateX(-50%)'.
That recipe only worked while the plugin positioned nothing itself: a
mobile viewport left the panel hanging off whichever edge the collapsed
root sat near. 0.3.7 added its own narrow-screen clamp, which writes an
INLINE 'left' (390px phone, new chat: root x=122, inline left=-106px —
i.e. 16px, exactly the right place). Inline 'left' wins over the
stylesheet, so the recipe's 'left' became dead weight while its
'transform' kept firing, shifting the panel half its width further left:
x = 122 + (-106) + (-168) = -152px, off screen by 152px — the reproduced
bug. Two layers each half-applied. The fix keeps repair 1 (the stretch
is still what welds the closed chip onto the [meter][send] cluster) and
replaces repair 2 with the card-anchored recipe above; 'transform:none'
is what neutralises the stray translateX, and 'left:0' is '!important'
because it must beat that inline value. Panel width stays the plugin's
own (336px, capped at 100%) so the Off/High/Max ticks keep their
designed spacing.
The panel opens bottom:calc(100% + 8px) above the card, but the card is
only 70-108px tall on a phone while the panel is 208px, so it always
overflows upward — that is the plugin's own desktop behaviour and is
kept. What is NOT acceptable is overflowing the VIEWPORT, so three
guards follow (the inner listbox, the keyboard-short phone, landscape).
Every guard stays inside this mobile media query: desktop untouched. */
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] [data-seat-root] {
flex: 1 1 auto;
justify-content: flex-end;
/* Keep parked here rather than in a second rule: the panel below anchors
to the card, not to this root. */
position: static !important;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] [data-seat-root] > [data-seat-panel] {
left: 0 !important;
right: 0 !important;
max-width: min(100%, 420px) !important;
transform: none !important;
margin-left: auto !important;
margin-right: auto !important;
}
/* Guard 1 — the panel's inner model listbox opens upward (bottom:58px
inside the panel) with max-height:min(220px, 100vh - 96px). On a short
screen that ceiling is taller than the room above the panel, so the
listbox pokes past the viewport top (320x568: menu y=-41). Clamp it to
half the viewport minus the panel's own 58px offset + padding. */
@media (max-height: 700px) {
[data-seat-model-menu] {
max-height: min(220px, calc(50vh - 110px)) !important;
}
}
/* Guard 2 — with the on-screen keyboard up (portrait, ≤505px tall) there
is not even 208px of room above the card. Let the whole panel scroll
inside itself instead of escaping the top; children keep their natural
height so the slider and its ticks are never squashed. */
@media (orientation: portrait) and (max-height: 505px) {
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] [data-seat-root] > [data-seat-panel] {
max-height: calc(50dvh - 45px) !important;
overflow-y: auto !important;
overscroll-behavior: contain !important;
}
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] [data-seat-root] > [data-seat-panel] > * {
flex: 0 0 auto !important;
}
}
/* Guard 3 — landscape: above the card there are only ~156px (the card
sits low, the panel is 208px), so an upward opening can never fit.
Detach the panel into a bottom-docked sheet instead; it floats clear
of the card and the viewport top. */
@media (orientation: landscape) {
[data-phase] [class*="_card"]:has(textarea, [data-composer-input]) [class*="_row"]:has([class*="_trailing"]) > [class*="_trailing"] [data-seat-root] > [data-seat-panel] {
position: fixed !important;
left: 16px !important;
right: 16px !important;
bottom: 12px !important;
top: auto !important;
transform: none !important;
margin: 0 auto !important;
max-width: min(100%, 420px) !important;
}
}
/* --- Composer file entry (0.1.6 host) ---
The 0.1.6-alpha.2 host deleted the composer's paperclip attach button, so
the only file entry left is the 文件 row inside the "+" listbox. The
control is contributed to the host-declared conversation.input.left list
slot ("Compact controls at the left of the composer tool row"), inside the
tools lane beside the plus button, so only its own box is needed here:
28x28 like the plus button and fixed — one of the row's hit targets, never
part of the adaptive shrink. Its click handler triggers the host's own
hidden input[type=file], so intake validation and upload stay host-owned. */
[data-composer-card] [data-mobile-nav="file-upload"] {
flex: 0 0 auto !important;
/* 2026-09-23 店主:"触发点有点小,没那么容易点" ⇒ 盒子 28×28 → 34×34
(面积 +47%),再由下面的 ::after 向外扩 4px(最终命中区约 42×42)。
**图标位置不变**:盒宽 +6 后 margin-left 从 -10 收到 -13,图标中心原地不动;
高度对齐发送键的 34px,行高不受影响。 */
/* 2026-10-07 店主:「点一下有一层深一点的灰色,也有一层浅一点的灰色,去掉一层」。
根因是**嵌套两层**:宿主的按钮盒底色(34x34、圆角 8)与我们的 ::before 胶囊
(28x28 圆)半透明叠半透明 —— 重叠区深、外圈浅。压层叠(!important)在真机上
压不住那一层(实测按下仍是 233 外圈 + 222 内层),所以改成**结构上只有一层**:
可见胶囊 = 按钮盒本身。盒子 28x28 + 全圆角,与加号同尺寸;图标中心不变
(盒宽 34→28 ⇒ margin-left 由 -11 收到 -8);命中区仍由 ::after 外扩。 */
width: 28px !important;
min-width: 28px !important;
max-width: 28px !important;
height: 28px !important;
min-height: 28px !important;
padding: 0 !important;
position: relative !important;
/* 左移 10px + 图标 14→16px(2026-09-23,店主:"太往右了、有点小"):
工具道现在是 [+][⚠⌄][📎],宿主给 modes 控件留了较宽的尾部留白,📎 看着
离左边一截。与参考图逐像素对齐(以 + 为锚点):参考 📎 墨迹 107..117 CSS,
我们原先是 114..123;而墨迹高度 48 vs 参考 54 物理 px ⇒ 图标 14 偏小,
换成宿主通用的 16(+ / ⚠ 都是 16)。28px 盒 + 16px 图标居中 ⇒ 墨迹左缘
= 盒左缘 + 8.85,故盒左缘取 98 ⇒ margin-left: -10px(吃掉 6px gap 后再
压进 modes 尾部留白 4px,不碰它的墨迹:chevron 墨迹止于 ~91)。
这一个数值就是"往左多少"的旋钮,可按眼睛调,别动别的。 */
margin: 0 0 0 -8px !important;
display: grid !important;
place-items: center;
border: 0 !important;
border-radius: 999px;
background: transparent;
color: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
/* 按下/悬停反馈:宿主其它按钮(加号常驻、模型与权限触发器按下)都有灰胶囊,
只有我们这个是纯透明、也没有 :active —— 店主 2026-09-23:"文件上传那个图标
怎么没有胶囊?"(点了没反应)。用宿主自己的 hover token,视觉与官方一致。 */
/* 可见胶囊只在 ::before 上画 28×28 的圆(与加号同尺寸,店主:"胶囊有点太大"),
按钮盒子仍是 34×34 + ::after 外扩 —— 命中区大、看起来小,两者解耦。 */
[data-composer-card] [data-mobile-nav="file-upload"]::before {
content: '';
position: absolute;
inset: 3px;
border-radius: 999px;
background: transparent;
transition: background .12s ease;
}
[data-composer-card] [data-mobile-nav="file-upload"]::before {
/* 胶囊已由盒子本体承担,这一层永久透明(保留节点是为了不惊动既有锚点)。 */
background: transparent !important;
}
/* 2026-10-07 店主:"点一下有一层深一点的灰色,也有一层浅一点的灰色,去掉一层"。
真因是**半透明叠半透明**,不是我们画了两层胶囊:宿主全局 CSS 里有
「button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}」
(特异性 0,2,1 > 上面那条基础规则的 0,2,0,所以它赢了)与
「button:active{background-color:#0000001f}」,两者都画在这个按钮的**盒子**上;
而 ::before 的可见胶囊又画一层 ⇒ 重叠区深、外圈浅,看着就是两层灰。
可见胶囊只留 ::before 一层:盒子在所有状态下强制透明(::after 只有命中区、无底色)。 */
[data-composer-card] [data-mobile-nav="file-upload"]:hover,
[data-composer-card] [data-mobile-nav="file-upload"]:active,
[data-composer-card] [data-mobile-nav="file-upload"]:focus-visible {
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
box-shadow: none !important;
outline: none !important;
border-color: transparent !important;
}
/* 压掉浏览器默认的淡蓝 tap 高亮(店主 2026-09-23:"单纯点击图标,出现一个淡蓝色
的原始的点击画面")。读源码取证:宿主头部那几个包(dsh-client-ui-subagent /
agent-preset / dsh-experimental-client-ui-agent-team / jobs)**都没有 :active、
也没有任何 tap-highlight 处理**,触摸设备上点"标准模式 / Agent Team / 1 个子代理 /
对话·轨迹"就会叠一层原始高亮;我们输入区的控件早已处理(见上面 file-upload 那组)。
做法与输入区同源:高亮透明,按下反馈交给宿主自己的 :hover/:active token
(那几个包各有 2~9 条 :hover 规则,触摸时 Chromium 会套用)。 */
/* 覆盖范围放宽:宿主有些控件不是 button(实测输入区里就有 [role=button]、带
tabindex 的 div 形态),所以三类一起收。 */
[data-mobile-nav="frame"] [data-phase] header button,
[data-mobile-nav="frame"] [data-phase] header [role="tab"],
[data-mobile-nav="frame"] [data-phase] header [role="menuitem"],
[data-mobile-nav="frame"] [data-phase] header [role="button"],
[data-mobile-nav="frame"] [data-phase] header [tabindex],
[data-composer-card] button,
[data-composer-card] [role="button"],
[data-composer-card] [tabindex] {
-webkit-tap-highlight-color: transparent;
}
/* 深色下顶栏与页面底色的断层(issue #176「减轻顶部纯黑区域与网页头部的突兀色差」/
2026-10-11 真机反馈:手机上头部呈灰黑一块,跟上面那行系统时间栏分得太开)。
实测(393×844,真实会话,深色):page/body 与 frame 都是 #151517(--dsw-alias-bg-base),
而宿主会话头部自带层色 #232324,头部上方还夹着一条跟随页面底色的安全区带
⇒ 「深色带 → 较亮头部 → 深色内容」三段断层。
处理:手机档(本块在移动媒体查询内)把头部底色并到页面底色,层次改由分隔线与内容承担;
浅色档宿主两头同为白、本来就没有断层,不参与;桌面档不碰。
宿主深色标记挂在 body 上(实测设 body[data-ds-dark-theme] 即切到 #151517),
html 上若也挂同一个标记一并覆盖。 */
body[data-ds-dark-theme] [data-mobile-nav="frame"] [data-phase] header.wSkVaW_header,
html[data-ds-dark-theme] [data-mobile-nav="frame"] [data-phase] header.wSkVaW_header {
background-color: var(--dsw-alias-bg-base, #151517) !important;
}
/* 壁纸兼容(issue #228):同一个插件把 --dsw-alias-bg-base 也置为 transparent
(见上方深色顶栏规则)。深色下顶栏用该令牌与页面同色,令牌被清空后顶栏
失去底色,消息滚动区从标题行下方穿过。壁纸启用时用该令牌自身的深色静态
定义补回原色;浅色档通用规则已经用未被覆盖的 --dsw-alias-bg-layer-1,
不需要新规则。门控含 data-ds-dark-theme,特异性 (0,4,2) 高于上方深色
规则的 (0,3,2),不依赖源码顺序。 */
/* @patch
id: wallpaper-header-fill
for: deepseek-harness-background 0.6.x 的 body[data-dsh-bg](壁纸启用时把 --dsw-alias-bg-base 置为 transparent)
why: 深色顶栏以该令牌与页面同色,第三方插件把它清空后顶栏失去底色,滚动内容从标题行穿过
until: 上游提供不透明的面板填充接缝,或不再把 --dsw-alias-bg-base 置空——届时本块删除
test: tests/wallpaper-surface-fill.test.ts
*/
body[data-ds-dark-theme][data-dsh-bg] [data-mobile-nav="frame"] [data-phase] header.wSkVaW_header {
background-color: var(--dsw-static-neutral-bluish-950, #151517) !important;
}
/* 头部 UI 的按下反馈(店主 2026-09-23:"是头部 UI,没有触发反馈")。
取证:头部四个宿主包 :active 全为 0,反馈只挂 :hover。
⚠ 第一版我给整颗 button 上 background-color,店主实测"胶囊过宽、跑到子代理下面"
—— 因为 button 盒比可见胶囊大(芯片文字只占盒的一部分)。所以改成**不改几何**的
按下效果:整体压暗(.92 ≈ 宿主 token 的观感强度;太淡店主会觉得"没变")。胶囊类的视觉仍由宿主自己的 chip 背景负责。
不用 position/伪元素:头部芯片里挂着宿主的弹层,改 position 会挪动包含块。 */
[data-mobile-nav="frame"] [data-phase] header button:active,
[data-mobile-nav="frame"] [data-phase] header [role="tab"]:active {
filter: brightness(.92);
}
/* 头部那些 v 的翻转:**标准模式那个现在会翻** —— 规则在本文件「DSHA 集成层:预设 chip」
那一块(搜 data-dsha-agent-preset="header" 的 svg:last-of-type 两条)。这里留一段纠正记录,
免得后人被已经作废的旧结论误导:
· 旧结论(同日早先写的)称"本 WebView 里该 svg 的 CSS transform 完全失效"——**错的**。
真因是预设 chip 的 > svg 上有一条我们自己写的
[data-dsha-agent-preset="header"] > svg { transform: none !important }(DSHA 集成层拿它把
图标拉回静态流)。**内联 transform: rotate(45deg) 没带 !important,被那条压掉**,
于是量出"盒子不变、computed 仍是 none",被我误判成 WebView 不吃 CSS transform。
· 子代理 chip 的 v 一直是宿主自带:dsh-client-ui-subagent 的
.ZKlsPq_trigger svg{transition:transform .12s} + 类 .ZKlsPq_triggerOpen{transform:rotate(180deg)}。
· 禁止对头部 svg 写通配规则(header svg{...} / [class*=chevron]{...}):那会覆盖子代理 chip
自己的 triggerOpen 状态,出现"修一个压掉另一个"(这正是当时反复翻车的原因)。 */
/* 输入区**宿主渲染**的功能键**不再自加胶囊**(店主 2026-09-23:"点击功能键怎么有两个
灰色的叠加?")。
原因:宿主本来就有自己的 hover 底色(conversation 包 13 条 :hover、model-selection 3 条、
permission-presets 2 条、input-trigger 3 条),我们再加一层 ::before 就是**两层灰叠在一起**。
教训:上一轮店主说"这几个功能没有触击反馈",我据此加了胶囊 —— 实际是那次刚把浏览器默认
淡蓝 tap 高亮压掉、观感反差的错觉;**宿主已有的反馈不要再叠一层**。
我们自己注入的 📎([data-mobile-nav="file-upload"])例外:宿主没有对应控件、也就没有底色,
它的胶囊留在上面那组规则里。 */
/* 命中区外扩:::after 属于按钮本身,一起参与命中测试,视觉完全不变。 */
[data-composer-card] [data-mobile-nav="file-upload"]::after {
content: '';
position: absolute;
inset: -7px;
border-radius: 999px;
}
/* A busy submit phase or a subagent session refuses attachments. The host
gates intake on canAcceptDrop (package-private), so this reads the closest
observable facts — input phase and subagent — and keeps the control from
opening a dialog the host would then reject. */
[data-composer-card] [data-mobile-nav="file-upload"]:disabled {
opacity: 0.38;
cursor: default;
}
/* The hidden input[type=file] this control triggers only exists on
0.1.6-alpha.2+ hosts (the rc generation's intake is paste/drop only),
while the host renders the input.left seat on both generations. Hide the
control wherever the host has no file input for it to trigger — its click
would otherwise be a permanent silent no-op there (review 2026-09-19). */
[data-composer-card]:not(:has(input[type=file])) [data-mobile-nav="file-upload"] {
display: none !important;
}
/* --- Composer vertical slack on mobile (0.1.6 host) ---
The host's own .card padding-top:8px + gap:12px and .row padding leave 29px of pure
blank space in a 98px single-line card (measured). Only the vertical slack is trimmed;
horizontal padding and both hit targets stay untouched. Scoped to the active phase on
purpose: the hero composer's input carries the host's own min-height floor, and trimming
it there re-creates the clip/scrollbar defect recorded under Pitfalls「hero 输入框下限」. */
/* DSHA:输入卡片自身留白偏大。宿主那两声明全出自它自己的
dsh-client-ui-conversation(.uV2eYG_card 是 padding-top:8px + gap:12px,
.uV2eYG_row 再吃 padding:2px 8px 6px),单行输入时卡片 98px 里有 29px
是纯空白。手机上只压纵向留白(真机实测 moderate 档):
卡片 98 -> 78、编辑器 36 -> 32、按钮行 42 -> 36、文字底到按钮顶 29 -> 19px。
横向 padding(8px)与两个按钮尺寸(28/34px)一律不动,触控目标不变;
编辑器仍是可增长的多行框(max-height 336px),只是单行时不再垫高。 */
[data-mobile-nav="frame"] [data-phase="active"] [data-composer-card] {
padding-top: 2px !important;
gap: 4px !important;
}
[data-mobile-nav="frame"] [data-phase="active"] [data-composer-card] [class*="_row"] {
padding: 0 8px !important;
}
[data-mobile-nav="frame"] [data-phase="active"] [data-composer-card] [data-composer-input],
[data-mobile-nav="frame"] [data-phase="active"] [data-composer-card] [class*="_scroll"] {
min-height: 28px !important;
padding-top: 2px !important;
}
/* --- Session header on mobile ---
Keep the host-owned metadata in one responsive row. The conversation
title, the mode text and the running/subagent status all keep their
words; the one tenant that yields width when a phone runs out of it is
the background-job trigger's verbose label ("1 background job running"),
while Files keeps its hit area. */
/* Both !important flags are load-bearing. The host's session-controller sheet
ships [data-dsh-frame] [data-dsh-responsive-part="conversation-header"] with
padding-left: 60px !important under (max-width: 768px), so a plain
declaration here loses however specific it is: measured 2026-09-13 at
390px, the computed padding-left stayed 60px and the title still began at
x=100 with our rule present, matching and later in source order. The value
is 0 because our own toggle already occupies that left seat (painted at
x=8-36), so the host reservation is pure dead space on a phone. */
[data-mobile-nav="frame"] [data-phase] header.wSkVaW_header {
padding-left: 0 !important;
padding-right: 8px !important;
position: relative !important;
/* Opaque AND above the flow, both required: the host bar has no background,
and the message flow is a later sibling, so it painted OVER the bar (three
headless screenshots 2026-10-07: transparent -> content read through the
title row; opaque but unraised -> byte-identical to transparent, i.e. the
background was painted underneath; opaque + raised -> pixel-identical to
the untouched band). The scroll box runs underneath because of the
"selection-drag autoscroll ramp" rule above (it lifts it by
--mobile-nav-header-h). Same surface token the panel ghost uses, so themes
carry over; the host keeps its --dsw-alias-border-l3 bottom border.
z-index 30 clears the flow's own raised layers (host banners measured at
z 6-7 and the marks slot / composer seat at 7 on the live host) while
staying under this plugin's overlay tier (55+ backdrops/panels, FAB 21 is
hero-only where the header is hidden, drawer/menus 1000+) so a dialog or
the drawer still covers the bar. */
background: var(--dsw-alias-bg-layer-1, #fff) !important;
z-index: 30 !important;
}
/* The hero phase's empty header must stay hidden on phones. The host hides
it via the headerHidden class at (0,1,0), but its own session-controller
sheet re-shows the conversation header as a grid at <=768px —
[data-dsh-frame] [data-dsh-responsive-part="conversation-header"] with
display grid at (0,2,0) — and that beats the hide on the very element
carrying both classes. Result measured 2026-09-19 at 390px: an empty 85px
header paints only its 1px border-bottom (--dsw-alias-border-l3) as a stray
gray hairline under the status bar (pixel-scanned at y=84-85,
rgb(224,224,224)); desktop keeps display none and no line. Our (0,3,1)
re-hide needs no !important: the grid rule's display is a normal
declaration and our style tag loads last. The header carries no children in
hero (drawer entry is the FAB), so hiding it frees the dead 85px too. */
[data-mobile-nav="frame"] [data-phase] header[class*="_headerHidden"] {
display: none;
}
/* 0.1.6-alpha.2 renamed the hero-empty marker: headerHidden -> headerBlank
(audit §1 row 3), so the rule above is a dead needle on alpha.2 and this
one is dead on rc hosts — together they cover both generations. Same
(0,3,1) shape, same no-!important reasoning as above. */
[data-mobile-nav="frame"] [data-phase] header[class*="headerBlank"] {
display: none;
}
/* Header popovers resolve against the header, not against their 28px flow
box. 0.1.5's background-job chip anchors its menu with
position:absolute; top:calc(100% + 5px) inside .QsffPG_root
{position:relative} — a 28px-tall chip — so the menu was laid out at
x=-16 (our right:8px resolved against that 156px chip root) and then
clipped twice: by our own overflow:hidden on the chip root and by the
host's [data-dsh-responsive-part="session-title-cluster"]
{overflow:hidden}. The chip still reported aria-expanded=true with
nothing painted and nothing hit-testable: measured 2026-09-14 at 390px,
menu rect [-16,49,336,40], elementFromPoint at its centre returned the
view tabs row. A positioned header plus a static chip root puts the same
menu at [46,77,336,73] — inside the viewport, its rows hit-testable, and
an outside tap still dismisses it (menus 1 -> 0). On 0.1.6-alpha.2 the
chip leaves the flow entirely (absolute, in the gated block below), and
this rule's higher specificity ((0,6,2) vs the new (0,4,1)) would pin it
static there too — so it is excluded on alpha.2 hosts via :not(:has(...))
and keeps governing rc hosts (review 2026-09-19).
BOTH halves are load-bearing: forcing the chip root static without
positioning the header moves the containing block out to the frame, and
the menu lands at x=8 y=849 — past the 844px viewport (A/B 2026-09-13).
Scoped to the header actions slot, so the subagent lineage root inside
the crumbs keeps its own anchored, fixed-position menu. */
[data-mobile-nav="frame"] [data-phase] header:not(:has([class*="_headerLeading"])) [class*="_headerActions"] [class*="_root"]:not([class*="_switcherRoot"]):has(> button[class*="_trigger"]) {
position: static !important;
}
/* The tab strip is a separate grid item from the title row and does not
inherit the title row's inset, so after the header padding above went to 0
it sat flush against the bezel (measured: tablist x=0, first tab 0..30
while the title starts at 40). Give it the same left inset as the toggle so
the two rows read as one column. */
/* ---------- 手机档专属:头部留白收紧(≤767px + coarse)----------
数值是照 360×754 真机量的(页签条 margin-top -4 / 页签下划线 5px /
页签按钮去上内边距、靠底对齐 / 标题行回到内容高度)。**只对真·手机档生效**:
768–1023 的平板档保持上游手机 UI 的排布,不套这台手机的魔数
(仓库既有惯例,见文件末尾的 DSHA 预设块)。
真机读数(修前 → 修后):头部 77 → 67px、标题↔页签文字间距 22 → 15px。 */
@media (max-width: 767px) and (pointer: coarse) {
[data-mobile-nav="frame"] [data-phase] header [class*="wSkVaW_tabs"] {
padding-left: 8px !important;
/* 2026-09-23 店主:"标题和下面『对话』中间的空白有点多"。
宿主给这条页签条 margin-top:10px,页签按钮自己还带 padding-bottom:9px
(给选中下划线留位),两行文字之间就空出一条。收紧:
margin 归零(真机 4 → 0)+ 下划线贴到 5px(页签条 36 → 31px)。
⚠ 选择器必须用**后代**:页签条外面套了一层 display:contents 的 div
(真机链:div.wSkVaW_tabs < div[0..0] < header.wSkVaW_header),
所以原来的「header > [class*="wSkVaW_tabs"]」是条死规则 —— padding-left
从来没生效过(现按后代写,值仍是实测的 8px,视觉不变)。 */
margin-top: 0 !important;
margin-bottom: 0 !important;
}
/* 2026-10-20 五轮(真机截图实测):上面那条 5px 一直没生效 —— 宿主按钮自带
padding: 0 0 9px,App 注入表又把它钉回 9px,所以文字离下划线 10px、离标题只有
7px(店主:「太挤了,把对话那一行移动贴近下面那条线」)。改用宿主稳定的
data-conversation-tabs 标记 + html body 前缀把特异性拉满,把下内边距压到 3px:
文字随之下沉 6px —— 贴近分隔线,同时标题侧的留白回来。 */
html body [data-mobile-nav="frame"] [data-phase] header.wSkVaW_header [data-conversation-tabs][role="tablist"] > button[role="tab"],
html body [data-mobile-nav="frame"] [data-phase] header.wSkVaW_header [data-conversation-tabs] > button[class*="wSkVaW_tab"] {
/* 2026-10-20:店主「先把刚刚的改动还原」——回到上一版定稿的 6 / 3
(总高仍是 16 + 9 = 25px,文字下沉、下划线不动)。后续再按店主给的目标间距调。 */
padding: 6px 0 3px !important;
}
/* 2026-10-20 修正:页签条的 margin-top **是有效的**(早前判定"无效"是误判 —— 当时
与行高改动叠在一起,测到的位移被行高变化掩盖)。经验公式(设备像素):
间距 ≈ 67.5 + 3.81 × (row-gap + margin-top);基线 margin-top 由本文件下方
「header:has(_headerLeading) [role="tablist"] { margin-top: 4px }」 给出。
终态 = row-gap 3.5 + margin-top −8 ⇒ 净 −4.5 ⇒ 实测 50(店主定稿)。 */
html body [data-mobile-nav="frame"] [data-phase] header.wSkVaW_header [data-conversation-tabs][role="tablist"],
html body [data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]) [data-conversation-tabs][role="tablist"] {
/* 必须压过本文件下方 header:has(_headerLeading) [role="tablist"] 的 4px((0,4,1)),
所以这里挂满 data 标记((0,5,3));只写 [role="tablist"] 会输。 */
margin-top: -8px !important;
}
/* 2026-10-20(终态):「标题↔对话」间距的唯一有效旋钮是 header 的 row-gap。
两行网格先回到内容高(App 注入表原本钉死 40px + 36px),再由 row-gap 撑缝:
页签行整体下移、header 跟着长高 ⇒ 标题侧留白变大、对话到下方黑线的距离不变。
实测(设备像素):auto/0 → 37;+6px → 60;+3.5px → 50(店主定稿值)。
页签条自身的 margin-top 被 App 表钉死,-4/-8/0/6px 四个值都无效,已放弃。 */
html body [data-mobile-nav="frame"] [data-phase] header.wSkVaW_header {
grid-template-rows: auto auto !important;
row-gap: 3.5px !important;
}
/* 页签行钉到内容高(16px 行高 + 5px 下划线位 = 21px):文字与下划线贴住行底,
不再在 36px 的老行高里居中,多出来的空白由上面的 row-gap 统一管理。 */
html body [data-mobile-nav="frame"] [data-phase] header [role="tablist"] {
min-height: 0 !important;
height: 21px !important;
align-items: flex-end !important;
}
/* 真机读数:头部的 grid-template-rows 被钉成固定的 40px 36px(宿主自己没写行高,
是 DSHA 那张表给的),于是标题行、页签行都各留一截死空间。改成 auto:两行各自
贴住内容,标题行按 36px 的预设 chip 走、页签行由页签按钮撑开。 */
/* 标题行实测 40px 高,而里面最高的东西是 36px 的预设 chip("标准模式")——
多出来的 4px 是死空间。让行高回到内容高度(用 auto + min-height:0,
不写死 36:将来标题簇里出现更高的东西(子代理谱系等)也不会被裁)。 */
html [data-mobile-nav="frame"] [data-phase] header.wSkVaW_header [class*="wSkVaW_titleRow"] {
height: auto !important;
min-height: 0 !important;
}
/* 页签按钮文字上方还有 6px 空白(按钮被容器撑到 32px 高、文字居中):去掉上内边距,
下内边距 5px 已在上面钉住(下划线位置不变)。 */
html [data-mobile-nav="frame"] [data-phase] header.wSkVaW_header [class*="wSkVaW_tab"] {
padding-top: 0 !important;
align-self: flex-end !important;
}
}
/* NOTHING extra here on purpose. The header's own padding is already forced
to 0 above, and the title row carries padding-left:40px of its own, so the
title lands at x=40 - the toggle's right edge (36) plus 4px. A negative
margin added on top of that over-corrected and pulled the title off the
left edge (measured 2026-09-13: crumb x=20, and the string's first glyph
painted partially outside the viewport), so the reclaim lives in exactly
one place: the header padding. */
[data-mobile-nav="frame"] [data-phase] header.wSkVaW_header > :first-child {
display: flex !important;
align-items: center;
box-sizing: border-box;
width: 100%;
min-width: 0;
gap: 2px;
/* Just enough for the toggle (28px at left:8 -> right edge 36) plus 4px of
breathing room; the host's 60px rail reservation is neutralised above. */
padding-left: 40px;
}
[data-mobile-nav="frame"] [data-phase] header.wSkVaW_header > :first-child > :first-child {
display: flex !important;
align-items: center;
flex: 1 1 auto;
min-width: 0;
gap: 2px;
}
/* The directory toggle stays at the far left of the header. */
[data-mobile-nav="toggle"] {
position: absolute !important;
left: 8px !important;
top: 12px !important;
z-index: 2 !important;
}
/* The files opener is pinned to the header's right corner, mirroring the
directory toggle on the left (same 8px edge, same 12px seat). In flow it
can never reach that corner: the host reserves the last 44px of the title
cluster for a utilities seat that is EMPTY on mobile - measured at 390px,
headerUtilities sits at x=374 with width 0 while the title cluster carries
padding-right: 44px - so the button stopped at x=300..328 and left 62px of
bare header to its right (2026-09-14 phone-side report: the opener is not
pinned to the top-right corner). Absolute positioning also returns its
28px of flow width to the title lane, and the containing block is the same
one the toggle resolves against, so both controls shift together with the
frame's safe-area padding. The 44px reservation itself is trimmed to the
28px band this button actually paints in the compact-rows block below, so
the title lane keeps the difference. */
[data-mobile-nav="files"] {
position: absolute !important;
right: 8px !important;
left: auto !important;
top: 12px !important;
z-index: 2 !important;
}
/* 手机档(≤767 + coarse):文件夹按钮右移 4px(原 8px),为标题行右簇再腾出空间。
与左侧 ☰ 的 8px 不再对称,是用户明确接受的取舍(2026-10-12 用户规格)。
平板档这一档没被要求动,所以留在这条手机档作用域里;共用档保持本批之前的值。 */
@media (max-width: 767px) and (pointer: coarse) {
[data-mobile-nav="files"] {
right: 4px !important;
}
}
[data-mobile-nav="frame"] [data-phase] header.wSkVaW_header [class*="_headerActions"] {
display: flex !important;
align-items: center;
box-sizing: border-box;
flex: 0 1 auto;
min-width: 0;
max-width: calc(100% - 32px);
margin-left: auto;
justify-content: flex-end;
gap: 2px;
}
/* The title takes the remaining width and never paints outside it; the
metadata lane's mode text is what shrinks first. */
/* min-width is a readable floor (2026-09-13 phone report: the title showed a
single glyph then an ellipsis). This lane has flex basis 0, so it is the
first thing every crowding neighbour eats: measured at 320px with a lineage
chip in the row, the crumb client width collapsed to 16px and NOTHING of
the title was painted. 30% of the row keeps 2-4 CJK glyphs plus the host's
own ellipsis whatever else is pinned next to it. */
[data-mobile-nav="frame"] [data-phase] header [class*="_crumbs"] {
flex: 1 1 0;
min-width: 30%;
max-width: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap !important;
}
/* Mode label: keep the icon AND the words. On a phone this chip is the only
mode switcher there is, so its text is not the surplus it was once
treated as: the longest preset name measured needs 121px including the
18px icon seat, while the old cap min(22vw, 220px) allowed just 85.8px at
390px — the text was clipped at every phone width even before the
crowding rules below pinned it to the icon alone (2026-09-14 phone
report: the mode label showed only its glyph). 38vw keeps the label whole
from 320px up and still lets it ellipsize before the title on wider
screens. */
[data-mobile-nav="frame"] [data-phase] header [class*="_label"]:has(> svg) {
order: 1;
flex: 0 1 auto;
min-width: 0;
max-width: min(38vw, 220px);
display: block;
position: relative;
box-sizing: border-box;
padding-left: 18px;
padding-right: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap !important;
}
[data-mobile-nav="frame"] [data-phase] header [class*="_label"]:has(> svg) > svg {
position: absolute !important;
left: 0 !important;
top: 50% !important;
transform: translateY(-50%) !important;
}
/* Running/subagent controls keep their full status text and hit area; they
do not give up width to the mode label. NOTE: the real subagent lineage
root has class="ZKlsPq_root " — a TRAILING SPACE from the plugin's
template-literal className — so [class$="_root"] never matches it. Use
[class*="_root"] and exclude the switcher root ([class*="_switcherRoot"])
so only the count/job roots get pinned (the switcher must stay shrinkable
so its own title can ellipsize). */
/* Pinned (flex 0 0 auto) with a max-width cap. A shrinkable chip is squeezed
below its content and the count reads as clipped or overwritten (the
2026-08-22 report), while a bare max-content pin eats the session title,
whose flex basis is 0: measured 2026-09-13 at 320px, the crumb went 68px
-> 16px and the painted title was EMPTY while the chip kept its full text.
Pinned + capped + the crumbs min-width floor above is what holds both —
the title ellipsizes, the count keeps its words, and the hit area stays
one inline-flex button.
NOTE: the popover containment lives with the header rules above, which
force this root position:static. That only works together with the
positioned header: static on its own moved the containing block out to
the frame and the menu landed at x=8 y=849, past the 844px viewport
(A/B 2026-09-13). */
[data-mobile-nav="frame"] [data-phase] header [class*="_root"]:not([class*="_switcherRoot"]):has(> button[class*="_trigger"]) {
order: 2;
flex: 0 0 auto;
min-width: 0;
max-width: min(40vw, 180px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap !important;
}
[data-mobile-nav="frame"] [data-phase] header [class*="_root"]:not([class*="_switcherRoot"]):has(> button[class*="_trigger"]) > button {
min-width: 0;
max-width: 100%;
}
[data-mobile-nav="frame"] [data-phase] header [class*="_root"]:not([class*="_switcherRoot"]):has(> button[class*="_trigger"]) > button > * {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
[data-mobile-nav="frame"] [data-phase] header [class*="_root"]:not([class*="_switcherRoot"]):has(> button[class*="_trigger"]) > button,
[data-mobile-nav="frame"] [data-phase] header [class*="_root"]:not([class*="_switcherRoot"]):has(> button[class*="_trigger"]) > button * {
white-space: nowrap !important;
}
/* The lineage count's leading "/" (ZKlsPq_separator — official desktop
chrome rendered only for a root session inside the crumbs) looks like a
stray extra breadcrumb level on small screens; hide it. The crumbSep "/"
between ancestry segments (subagent sessions) is a real separator and
stays. */
[data-mobile-nav="frame"] [data-phase] header [class*="_crumbs"] [class*="_separator"] {
display: none !important;
}
/* The header's right-hand slot clips its own dropdown away (0.1.5 host bug).
wSkVaW_headerUtilities is a 44x44 grid cell with overflow:auto, and the host
mounts its "More actions" menu INSIDE it: the menu is 218x52, so the cell
clipped it to 44x44 and the menu was never painted and never hit-testable
(measured: menu rect 156,56 218x52, computed flex/visible/opacity 1, yet
elementsFromPoint at the item centre returned the view tabs row and nothing
from the menu). Raising the menu z-index cannot help - the cell's own
stacking context traps it. Releasing the overflow paints the menu where the
host positioned it, and the item then works (verified: a real tap opening
the session-log export dialog, menus 1 -> 0 dialogs 1). Scoped to the mobile
branch and to this one cell, so desktop keeps the host layout. The section
is hidden on mobile anyway - the drawer footer carries the same action - but
the release stays for any plugin that registers a header dropdown here. */
[data-mobile-nav="frame"] [data-phase] header [class*="wSkVaW_headerUtilities"] {
overflow: visible !important;
/* The seat is empty on a phone (its only button is hidden just below) yet
still 44px tall, which floors the whole title row — see the compact-rows
block after the tab strip. */
height: 30px !important;
min-height: 0 !important;
}
[data-mobile-nav="frame"] [data-phase] header [class*="wSkVaW_headerUtilities"] [class*="nL4_yW_moreButton"] {
display: none !important;
}
[data-mobile-nav="frame"] [data-phase] header [data-mobile-nav="files"] {
width: 28px;
}
/* Session log download: gone from the header row on mobile (the utilities
seat holds only the session-log-export capsule).
⚠ 作用域必须是会话顶栏本身(header.wSkVaW_header),不能写成裸 header:
提问卡(dsh-client-ui-user-questions)的 Mbwy4a_frame 就渲染在 composerSeat 里、
也在 [data-phase] 之下,它的 Mbwy4a_header > headingBlock > :last-child 正是问题正文
。裸 header 版本把问题正文整行 display:none —— 店主「你提的问题被吞掉了」即此
(2026-10-20 真机 DOM 取证:h2 computed display:none,逐条禁用本样式表内的规则定位到
这条)。顶栏的签名类由宿主 ConversationRoot.module.css 的 .header 提供(wSkVaW_header),
本文件其它顶栏规则同样以它/其子类作代际锚。 */
[data-mobile-nav="frame"] [data-phase] header.wSkVaW_header > :first-child > :last-child {
display: none !important;
}
/* View tabs strip (official [role="tablist"] under the crumbs row).
Desktop ships a single flex row (gap: 36) sized for the two stock tabs
(对话/轨迹). Plugins register further views (memory / skill / todo
panels, per-plugin settings pages), and once the count passes two the
shrinkable buttons collapse to their min-content: CJK labels stack one
glyph per line (staircase), latin labels break word-per-line — the
strip eats a screenful of vertical space (#41, 8 tabs, HarmonyOS
browser). Scroll the strip horizontally instead — the standard mobile
tab-bar pattern — with every label kept whole (flex-shrink: 0 +
nowrap). Affordance is the peek: the naturally cut-off tab at the right
edge says "more this way" (unlike the settings navList, whose buttons
nearly fit and would show no cut edge), which is why this strip scrolls
while that one wraps. touch-action: pan-x opts the strip into
horizontal panning — the root's pan-y intersection stops at this first
scroll container (same mechanism as the drawer's pan-y), so the page
never scrolls sideways. overscroll-behavior-x: contain stops a flick
from chaining past the ends; snap keeps tabs edge-aligned after a
fling; the scrollbar stays hidden like every native tab bar. */
[data-mobile-nav="frame"] [data-phase] header [role="tablist"] {
flex-wrap: nowrap;
gap: 0 16px;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
touch-action: pan-x;
scrollbar-width: none;
}
[data-mobile-nav="frame"] [data-phase] header [role="tablist"]::-webkit-scrollbar {
display: none;
}
[data-mobile-nav="frame"] [data-phase] header [role="tablist"] > button {
flex-shrink: 0;
white-space: nowrap;
scroll-snap-align: start;
}
/* Compact session header rows (2026-09-14 phone report: the top is very
empty). The host's own mobile sheet lays the header out as
grid-template-rows: minmax(32px, auto) minmax(44px, auto) with
[role="tab"] { min-height: 44px }, and both rows then grow to 44: the
title row is floored by the empty utilities seat above, the tab row by the
buttons' own floor. Measured at 390px: header 97px = 8 padding + 44 + 44 +
1 border, for 36px of painted content. Capping the rows at 36/32 and the
tabs at their own content height gives 77px, with nothing else degraded —
title, mode text, status chips, chevrons and both pinned corner buttons
keep their measured geometry, and the tab strip keeps its #41 contract
(horizontal scroll, 16px gap, whole labels, pan-x).
The host's 8px padding-top is deliberately kept: the title row's 28px
content then centres at y=26, exactly the centre of the pinned corner
controls (toggle and Files opener both sit at top:12, 28px tall). Trimming
that padding to 4 shaved 4 more px but left the text row visibly riding
above both buttons (2026-09-14 phone report: the text row sits too high
against the drawer and Files controls), so the row height is what pays for
the compaction, not the alignment.
:has(> *) guards the hero header: it is an EMPTY, host-hidden grid that
still occupies 85px while the composer is laid out under it. In the hero
the header has 0 element children, so the guard leaves it at its official
height — measured, the hero composer rect [0,349,388,231] is identical
with and without this block. */
[data-mobile-nav="frame"] [data-phase] header:has(> *) {
min-height: 0 !important;
/* 2026-09-23 二轮:页签行地板 32 → 26(店主:"标题和下面『对话』中间空白有点多")。
标题行地板保持 36 —— 它下面的文字要跟 top:6 的圆形按钮对齐(实测文字中心
y=20 = 圆形按钮中心),压标题行会把文字顶得比按钮高(2026-09-14 已踩过)。
页签按钮的 32px 地板同理下到 26px(文字 16px + 下划线留 5px),
页签条的下沿随之从 76 收到 66。 */
grid-template-rows: minmax(36px, auto) minmax(32px, auto) !important;
}
[data-mobile-nav="frame"] [data-phase] header [role="tab"] {
min-height: 32px !important;
}
/* 手机档专属(≤767px + coarse):页签行地板 32 → 26(下划线收到 5px 后仍够点)。
平板档保留 32px 的既有值,不跟手机一起压。 */
@media (max-width: 767px) and (pointer: coarse) {
[data-mobile-nav="frame"] [data-phase] header:has(> *) {
grid-template-rows: minmax(36px, auto) minmax(26px, auto) !important;
}
[data-mobile-nav="frame"] [data-phase] header [role="tab"] {
min-height: 26px !important;
}
}
/* The title cluster reserves its last 44px for that empty utilities seat.
The lane's right edge must stay clear of the Files opener's HIT BOX,
otherwise the opener eats the trailing chips' taps. The pre-2026-09-22
value (26px, a width optimisation) only cleared at the 390px test width:
measured at 360x754 (dpr 4) the Agent Team chip ran to x=334 while the
opener's 36px box started at x=316 — an 18px overlap, so tapping the
chip's tail opened the Files panel. The reference phone UI shows the lane
ending at ~314 with the opener box at 316..352, i.e. the full 44px seat
plus 2px of breathing room, so restore that instead of narrowing the
opener: 46px clears the 36px box at right:8 by 2px at every width. */
[data-mobile-nav="frame"] [data-phase] header [class*="wSkVaW_titleCluster"] {
/* 本批之前的值。2026-10-12/13 那串「46 → 40 → 36 → 32 → 26 → 34」的实验
**只针对手机档**(后台任务圆点搬进标题行后与预设 chip 的间距),已整串
搬进下面那条手机档作用域;共用档(= 平板档)保持本批之前的 46px 不动。
预留从 padding 改记 margin(2026-10-17):标题簇是 overflow:auto 的滚动
容器,内容溢出时的裁剪线是 **padding box** —— 写在 padding 上会留下 46px
的「可绘制溢出区」,挤爆的那一截 chip 正好画进预留带、被绝对定位的文件
按钮压住(真机症状:预设 chip 末字被文件夹图标盖住)。改记 margin 后簇的
border box 收在预留带左侧,溢出再大也止步于按钮之前,内容宽度不变
(345 − 46 ⇄ 299 − 0)。 */
padding-right: 0 !important;
margin-right: 46px !important;
}
/* Header crowding on narrow phones.
Three tenants want the same row: the session title, the mode chip and the
status chips. The mode chip is the only mode switcher a phone has and the
title is the only session identity, so both keep their words and the title
ellipsizes instead (measured 2026-09-14 at 390px with a lineage chip
present: after this the mode label keeps 101px of text and the crumb
135px).
The lineage root (dsh-client-ui-subagent) sits in the crumbs for BOTH
running and idle descendants, so the guards below key on that root rather
than the transient running-state dot — otherwise the row would reflow the
moment agents go idle. Match roots with [class*="_root"] (the real class
carries a trailing space; [class$="_root"] matches nothing).
The background-job label used to yield here through two width-gated rules
(<=440px, and <=559px when the lineage chip was present). Both were keyed
on the chip's classed wrapper, which host 0.2.1 no longer renders, so they
are deleted: the label is now hidden unconditionally by the job-chip rule
in the phone-scope section (see header-job-chip-collapse). */
/* With the subagent lineage (any state) AND a background job present
together, 390px cannot hold the title, the mode words and the lineage
count at once; the crumb gives 8px back to the action lane. */
@media (max-width: 559px) {
[data-mobile-nav="frame"] [data-phase] header [class*="_crumbs"] {
padding-right: 8px;
}
}
/* Last resort on 320px-class screens: the title and both status chips cannot
share the row with the mode words, so the mode chip keeps only its icon. */
@media (max-width: 359px) {
[data-mobile-nav="frame"] [data-phase] header:has([class*="_crumbs"] [class*="_root"]):has([class*="_headerActions"] [class*="_root"]) [class*="_label"]:has(> svg) {
display: none !important;
}
}
/* --- 顶栏状态 chip 座位契约:**暂缓**(2026-10-12 受控实验后整块撤回)---
用户规格:「子智能体 chip 固定不变,后台任务 chip 出现在它左侧、间隔 2px」。
首版用 flex 容器 + order + gap 实现,**受控实验证明该机制不成立**:
子智能体 chip(ZKlsPq_root)在 alpha.2 代宿主上被插件自己钉成 position:absolute,
绝对定位元素脱离文档流,flex 的 order 与容器 gap 都决定不了它的落点 ——
注入一颗合成 jobs chip 后该 chip 实测从 x=292 跳到 x=174(视觉间距 −211px),
三个目标点无一达成;容器 computed gap 也仍是宿主自己的 4px。
真实 jobs chip 无法在受控环境构造(宿主在后台任务数为 0 时整颗不渲染),
故本条**已整块撤回**,等拿到「真机带后台任务的截图 + 视觉稿」后按绝对定位口径重做。
撤回是安全的:本组只在两颗 chip 同时在场时匹配,撤掉后单 chip 形态回到既有那批
规则的管辖(受控实验:单 chip 的 rect 与撤前逐像素一致,规则匹配数=0)。 */
/* --- Header popovers on mobile (dsh-client-ui-jobs / dsh-client-ui-subagent) --- */
/* Both entries sit in the session header and both anchor their panel to the
trigger's left edge (left:0 inside their own root), so clamp them to the
viewport. The background-job menu resolves against the header (see the
containment rules at the top of this section) and the subagent lineage
menu is position:fixed, so right:8px pins either panel 8px from the
phone's right edge: measured [46,77,336,73] for the job menu and
[38,41,336,58] for the lineage menu at 390px, both fully inside the
viewport. Do NOT clamp with left:8px: measured, that put the panel at
x=350..686 (off-screen) against a right-anchored x=30..366. */
[data-mobile-nav="frame"] [data-phase] header [class*="_menu"] {
left: auto !important;
right: 8px !important;
width: min(336px, calc(100vw - 16px));
max-width: none;
max-height: min(420px, calc(100dvh - 120px));
}
/* --- 0.1.6-alpha.2 session-header adaptation (audited on a real device) ---
The 16-item reconciliation in docs/upstream/2026-09-19-mobile-header-0.1.6-adaptation.md,
landing the 14 items whose anchors exist in 0.1.6-alpha.2 host builds. Two preset items
(#6/#7) are deliberately omitted: they anchor on .dsha-preset-header-anchor, a marker
that exists only in the DSHA build, so they would be dead rules here.
GENERATION GATING: only _headerLeading/_crumbCurrent/_crumbSeg/_headerCorner are
alpha.2-only classes — every other anchor below (_titleCluster/_crumbs/_headerActions/
_headerUtilities/tablist/QsffPG_/ZKlsPq_ and the :first-child chains) also exists on
0.1.5-rc hosts, where these rules would silently re-tune geometry the older rules
measured (review 2026-09-19). Every selector therefore carries
header:has([class*="_headerLeading"]): the whole block is dead on pre-alpha.2 hosts and
the rc-generation rules keep governing there unchanged. */
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]) {
/* 顶部留白收窄:宿主 header 自带 padding-top: 10px、标题行再垫 2px,
叠在刘海/状态栏避让之上就显空。这两处一起清零。 */
padding-left: 8px !important;
padding-right: 8px !important;
padding-top: 0 !important;
/* 宿主 header 有 min-height: 76px,而内容只有 ~69px,底部会垫出 7.6px 空白
(实测:标签行底边 106,header 底边 113.6)。贴底定位的状态 chip 会被这
段空白顶下去、和标签行错开。手机上让 header 贴住内容高度。 */
min-height: 0 !important;
}
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]) > :first-child {
flex-wrap: nowrap !important;
align-items: center !important;
gap: 0 !important;
padding-left: 32px !important;
padding-right: 0 !important;
padding-top: 0 !important;
}
/* 目录开关跟着一起上移,保持与标题/按钮同一行居中。 */
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]) [data-mobile-nav="toggle"] {
top: 6px !important;
}
/* 空座位不判空、只塌宽:a2 槽位渲染器永远在 headerLeading 里挂一个
[data-slot] 包装元素(display:contents、0×0),:empty 与 :not(:has(*))
两种「空」判定都恒不命中(宿主自己的 :empty 规则同样失效),而
display:none 又会在某代真的渲染控件时误藏真控件。这里不判定空不空,
只把第三方误标进来的预留 padding 塌掉——web-all 兼容层按 0.1.5 结构
把本座位误标成 session-title-cluster,注入 padding-inline-end:44px,
座位于是 0 内容 + 44 padding = 44px 死占(实测 390px:座位
[40,22,44,0]、titleCluster 被顶到 x=84)。padding 归零后空座位 = 0×0,
真有内容的宿主也不受影响(内容盒照常渲染)。 */
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]) [class*="_headerLeading"] {
/* 不能塌掉左侧座位本身:上面那条(> :first-child)给本座位留了
padding-left:32px 作面板开关的座位,而 padding: 0 !important 是
简写,会把它一并清零。两条规则特异性同为 (0,4,1),按源序本块在后
⇒ 简写胜出,座位塌成 0×0、网格第一列 0px、标题直接压到 left:8 的
面板按钮上(真机实测 2026-09-22,360x754@4:crumbs x=8、
toggle 8,6 28x28;真机 DOM 规则枚举确认胜出者就是本块)。
NOTE: 本文件整体是 JS 模板字符串,注释里绝不能出现反引号。 */
padding: 0 !important;
padding-left: 32px !important;
}
/* --- 0.2.0-rc.x:header 变 grid,座位从 titleRow 内部搬到了 header 自己的第一列 ---
上面两条把「面板开关的 32px 座位」记在座位自己身上(padding-left)。在
0.1.6-alpha.2 里 headerLeading 是 titleRow 的第一个孩子,这个 padding 就是
titleRow 内容盒的起点,标题的 40px 落点是它直接算出来的。0.2.0-rc.x 把 header
改成 grid-template-columns: auto minmax(0,1fr):座位成了 header 的直接孩子
(grid-area: 1/1),titleRow 独占第二列(grid-column: 2),于是标题的 40px 落点
变成「网格第一列恰好 32px」的副作用——座位一旦被谁塌掉(padding 被覆盖、宽度被
第三方归零、display 被藏),第一列就回到 0px,标题立刻压到 left:8 的圆形开关上。
2026-09-22 在 0.1.6 上实测过同一个失败面(座位被 padding 简写清零 → 第一列 0px
→ crumbs x=8、toggle 8,6 28x28),当时靠把 padding-left 补回来止住;这一代改成
把座位记在标题行自己身上:padding-left 加在 titleRow 上(border-box,不撑破第二
列),座位格归零,标题落点不再依赖座位格存在、也不依赖它的宽度算得对不对。
判别式是「座位是不是 header 的直接孩子」——0.1.6 及更早座位在 titleRow 内部,
整块不命中,老规则原样管老宿主。
附带修掉一条静默漂移:老规则「header > :first-child > :last-child { display:none }」
在 0.1.5 藏的是会话日志胶囊;0.2.0 里 header 的第一格是座位,它最后一个孩子是
conversation.header.leading 槽的包装元素,整条规则于是变成「藏掉整个 leading 槽」
(实测:往槽里注入 28x28 按钮,计算盒 0x0)。手机档这一格是插件自己的抽屉开关
(绝对定位 left:8),槽里的控件必然和它叠在一起,所以「藏」的结果是对的、理由
一直没写下来;这里按槽名显式藏,别再靠 :last-child 误伤。
NOTE: 本文件整体是 JS 模板字符串,注释里绝不能出现反引号。 */
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]):has(> [data-conversation-header-leading]) > [data-conversation-header-leading] {
width: auto !important;
min-width: 0 !important;
padding: 0 !important;
gap: 0 !important;
}
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]):has(> [data-conversation-header-leading]) [data-slot="conversation.header.leading"] {
display: none !important;
}
/* 这里用 [class*="_titleRow"] 后缀形而不是邻近两条的 [class*="wSkVaW_titleRow"]:
全包只有 wSkVaW_titleRow 含这个片段(无前缀重叠,且已被 header:has 门控),
后缀形对哈希前缀改名免疫——座位记错地方的代价是标题静默压回开关上,不值得
为风格统一冒这个险。 */
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]):has(> [data-conversation-header-leading]) [class*="_titleRow"] {
box-sizing: border-box !important;
padding-left: 32px !important;
}
/* 0.1.6 的新头部里,titleRow 的第一个孩子是新增的空座位
headerLeading(macOS 桌面控件,安卓上渲染 null)。插件按 0.1.5 老结构
写的「header > :first-child > :first-child { flex: 1 1 auto }」现在套在
这个空座位上,于是它吃掉全部剩余宽度、把标题顶到右侧(实测 411px 宽
屏幕上标题被推到 131px 处)。让它不参与伸缩即可——有内容时也不会塌。 */
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]) > :first-child > :first-child {
flex: 0 0 auto !important;
width: auto !important;
min-width: 0 !important;
gap: 0 !important;
}
/* 第三方兼容层(@linxin666/dsh-web-all 的 web-ui-compat 行)按 0.1.5 结构
把本代 titleCluster 误标成 session-utilities,给里面所有按钮注入
min-width/min-height:44px + flex:none:toggle/files、模式/团队/面包屑
按钮全被顶成 44 —— toggle (8,6,44,44) 中心 28、files (338,2,44,44)
中心 24、标题带中心 22 三心不齐;files 加宽后越过 headerActions 流右缘
6px(338 < 344)。宿主 0.1.6-alpha.2 自身没有任何 44px 下限(全包
grep 零命中),这里把外来下限归零:控件回到各自设计尺寸(toggle 28
来自 base.css、files 36 来自下面的 a2 专条、chips/面包屑回宿主自然
高度),三心回到 20,titleCluster 的 min-height:40 !important 重新
主导行高。QsffPG/ZKlsPq 两个状态 chip 用 :not 明确豁免:它们的
25px 下限由后面 min-height:25px !important 专条供给,特异性 (0,4,1)
低于本条 (0,7,1),不豁免会被顺手压掉,不靠书写顺序。 */
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]) [class*="_titleCluster"] :is(button, [role="button"]):not([class*="QsffPG_root"] button):not([class*="ZKlsPq_root"] button) {
min-width: 0 !important;
min-height: 0 !important;
}
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]) [class*="_titleCluster"] {
display: flex !important;
flex-wrap: nowrap !important;
flex: 1 1 auto !important;
width: auto !important;
max-width: none !important;
min-width: 0 !important;
min-height: 40px !important;
/* 三级间隙 6 → 4(2026-09-23 用户拍板):团队 chip 在手机档被宿主
@container(width<=480px) 藏掉标签、只剩 14px 图标(对账见 §6),
这一行不再需要 6px 的呼吸量;收成 4px 让「模式 / 团队 / 文件夹」
看起来是一组。 */
/* 本批之前的值。2026-10-12 的「标题这一行三个之间放宽到 6px」只针对手机档
(后台任务圆点搬进标题行后与预设 chip 的间距),已搬进手机档作用域。 */
gap: 0 4px !important;
justify-content: flex-start !important;
align-items: center !important;
/* 簇溢出守卫,随断点 A 无条件化并入本显示规则(原为独立条):极端
字体下 crumbs 触地板后的残余溢出保持可横滑,不依赖 web-all 垫片
(缺席时簇溢出默认 visible,会压画到 corner 按钮上);内容放得下时
本声明完全惰性。x:auto 把 y 也算成 auto,簇内容高 ≤40px 恒不纵溢
无实害;findHorizontalScroller 对 overflow-x 容器让位。 */
overflow-x: auto !important;
}
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]) [class*="_titleCluster"] > [class*="_crumbs"] {
/* 标题改成自适应:面包屑条吃掉动作区之外的剩余宽度,标题多长就显示多少,
装不下时由每一段自己的滑动窗口(见下)横向滑。min-width 保底 4 字,
防止预设名字很长时把标题挤没。 */
flex: 1 1 auto !important;
width: auto !important;
min-width: 72px !important;
max-width: none !important;
margin-left: 0 !important;
margin-right: 0 !important;
min-height: 0 !important;
padding-right: 0 !important;
overflow: visible !important;
white-space: nowrap !important;
}
/* 标题本体:自适应宽度 + 横向滑动。宽度由上面面包屑条的剩余空间决定,
装不下时在本段内左右滑(touch-action: pan-x 让浏览器先认领横滑,
左缘抽屉手势不会抢走这一笔)。 */
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]) [class*="_crumbs"] [class*="_crumbCurrent"] {
flex: 0 1 auto !important;
width: auto !important;
min-width: 0 !important;
/* 6 个汉字上限:6×14px + 左右 padding 16px = 100px。再长就在本段内横滑,
这样标题永远不会顶到右侧的预设。 */
/* 本批之前的值(100px 横滑窗 + 硬切)。2026-10-12 的「缩到 80px 再改省略号、
最后取消硬顶改 max-width: 100%」只针对手机档(子智能体 chip 进标题行后
要让出约 100px),已整串搬进手机档作用域;共用档(= 平板档)保持不动。 */
max-width: 100px !important;
overflow-x: auto !important;
overflow-y: hidden !important;
text-overflow: clip !important;
white-space: nowrap !important;
text-align: left !important;
justify-content: flex-start !important;
touch-action: pan-x !important;
overscroll-behavior-x: contain !important;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
[data-mobile-nav="frame"] [data-phase] header:has([class*="_headerLeading"]) [class*="_crumbs"] [class*="_crumbCurrent"]::-webkit-scrollbar {
display: none;
}
/* 面包屑的父会话段同样是