/**
* Mobile stylesheet for the DSH web shell.
*
* Hooks are the stable framework attributes only — no hashed classes:
* - `[data-mobile-nav="frame"]` our marker on the AppFrame element (value-scoped so
* it never matches the plugin's own controls)
* - `[data-sidebar-collapsed]` AppFrame: sidebar is in the compact rail state
* - `[data-side="sidebar"|"details"]` AppFrame drag handles
* - `[data-shell-overlay]` AppFrame overlay layer (used to locate the frame)
* - `[data-phase]` conversation root phase (hero|active|settling)
*
* Below the official auto-collapse breakpoint (1024px) the rail is removed
* from the grid entirely; the sidebar column becomes an overlay drawer that
* slides in when the frame leaves the collapsed state (narrowExpanded).
*/
export const MOBILE_CSS = `
/* ---------- base control styles (rendered at any width, hidden where unused) ---------- */
[data-mobile-nav="toggle"],
[data-mobile-nav="files"] {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex: none;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--dsw-alias-label-secondary, inherit);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
[data-mobile-nav="toggle"]:hover,
[data-mobile-nav="files"]:hover {
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
}
[data-mobile-nav="toggle"]:focus-visible,
[data-mobile-nav="files"]:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary, #4f6ef7);
outline-offset: 1px;
}
/* Drawer footer actions: the relocated Session log download plus the Files
action that opens the dsh-web-ui explorer sheet. */
[data-mobile-nav="drawer-actions"] {
display: inline-flex;
align-items: center;
gap: 8px;
}
/* 宿主没有 aionui explorer 列(官方 DSH 无 dsh-web-ui,issue #48)时隐藏
移动端「文件浏览」入口(header 图标 + drawer footer 项)——不然点了没反应。 */
[data-mobile-nav-explorer="0"] [data-mobile-nav="files"],
[data-mobile-nav-explorer="0"] [data-mobile-nav="explorer"] {
display: none !important;
}
[data-mobile-nav="session-log"],
[data-mobile-nav="explorer"] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 34px;
padding: 0 12px;
border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12));
border-radius: 12px;
background: transparent;
color: var(--dsw-alias-label-primary, inherit);
font-family: inherit;
font-size: 13px;
line-height: 20px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
[data-mobile-nav="session-log"]:hover:not(:disabled),
[data-mobile-nav="explorer"]:hover {
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
}
[data-mobile-nav="session-log"]:disabled {
color: var(--dsw-alias-label-dimmed, rgba(0, 0, 0, .35));
cursor: default;
}
/* Floating fallback button (hero / blank phases without a session header).
The top clears the camera band below the status bar; when the client has
set viewport-fit=cover the safe-area inset moves it below the notch too. */
[data-mobile-nav="fab"] {
position: absolute;
top: calc(env(safe-area-inset-top, 0px) + 72px);
left: 10px;
z-index: 21;
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
padding: 0;
border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12));
border-radius: 50%;
background: var(--dsw-alias-button-floating-fill, #ffffff);
color: var(--dsw-alias-label-primary, inherit);
cursor: pointer;
box-shadow: 0 2px 12px rgba(0, 0, 0, .18);
-webkit-tap-highlight-color: transparent;
}
[data-mobile-nav="fab"]:hover {
background: var(--dsw-alias-button-floating-hover, rgba(0, 0, 0, .08));
}
[data-mobile-nav="fab"]:focus-visible {
outline: 2px solid var(--dsw-alias-state-business-primary, #4f6ef7);
outline-offset: 2px;
}
/* Dimmed backdrop under the open drawer; above every column, below the drawer.
pointer-events: none —— 点击穿透(issue #38):backdrop 只负责视觉压暗,
不抢点击。关闭抽屉改由 MobileNavOverlay 的 document 级「抽屉外点击」监听处理
(等价于原来的点击遮罩关闭,且抽屉内点击不再被 backdrop 吃掉)。 */
[data-mobile-nav="backdrop"] {
position: absolute;
inset: 0;
z-index: 30;
background: rgba(0, 0, 0, .45);
pointer-events: none;
animation: dsh-mobile-nav-fade .2s var(--ds-ease-in-out, ease-in-out);
-webkit-tap-highlight-color: transparent;
}
@keyframes dsh-mobile-nav-fade {
from { opacity: 0; }
to { opacity: 1; }
}
/* Settings sheet entrance: the official dialog mounts with no animation at
all, so it snaps in. Fade + slight rise/scale reads as a proper sheet. */
@keyframes dsh-mobile-nav-sheet-in {
from {
opacity: 0;
transform: translateY(14px) scale(.98);
}
to {
opacity: 1;
transform: none;
}
}
/* Preview sheet rise: the aionui preview column opens as a bottom sheet. */
@keyframes dsh-mobile-nav-sheet-up {
from {
opacity: 0;
transform: translateY(28px);
}
to {
opacity: 1;
transform: none;
}
}
/* ---------- mobile-only layout ---------- */
@media (max-width: 1023px) {
/* --- Phone chrome ---
The system status bar stays visible (no fullscreen). Two adjustments
make it behave:
- touch-action: manipulation kills double-tap-to-zoom (and the 300ms
tap delay) while keeping pan and pinch zoom; the client also
suppresses legacy-iOS gesturestart as a fallback.
- 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: manipulation !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). */
[data-mobile-nav="frame"] {
position: relative !important;
grid-template-columns: minmax(0, 1fr) 0 0 !important;
padding-top: env(safe-area-inset-top, 0px) !important;
}
/* 主内容列(第 2 个网格子元素)在官方样式里有显式 grid-column: 2——
网格被压缩成 [1fr, 0, 0] 后它会落在 0px 的第 2 轨,整个主界面被挤出
视口(只剩背景图)。必须显式把它拉回第 1 轨(issue #5)。
第 3 列(details)保持 0 轨即可,无需处理。 */
[data-mobile-nav="frame"] > :nth-child(2) {
grid-column: 1 !important;
grid-row: 1 !important;
min-width: 0 !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.
Z-index note: the backdrop renders inside the shell's overlay layer
([data-shell-overlay]), which forms its own stacking context. Third-party
plugins can force that layer up with !important (dsh-update-checker sets
it to 500), and when the layer outranks the drawer, the backdrop paints
ABOVE the drawer and swallows every tap — the drawer opens but no row
can be pressed (every tap just closes it). The drawer must therefore
outrank any such raise.
1200 (was 600) clears the mobile layers shipped by
@linxin666/dsh-web-ui-all — its sidebar pane is z-index 1100, its
details pane 1000 and its full-screen frame ::after mask 1050 (issue
#67: that mask sat on top of the 600 drawer and ate every tap). Still
far under the fixed-position banners/toasts (z 9999) that float at the
viewport level. */
[data-mobile-nav="frame"] > :first-child {
position: absolute !important;
inset: 0 auto 0 0 !important;
width: max-content !important;
max-width: 92vw !important;
z-index: 1200 !important;
transform: translateX(-110%);
transition: transform .28s var(--ds-ease-in-out, ease-in-out);
background: var(--dsw-alias-bg-base, #ffffff);
/* 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;
}
/* 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;
}
/* Kill a competing full-screen mask (issue #67).
@linxin666/dsh-web-ui-all ships its own mobile drawer, and part of it is
[data-dsh-frame]:not([data-sidebar-collapsed])::after {
content: ""; position: fixed; inset: 0; z-index: 1050;
background: rgb(0 0 0 / 24%);
}
The pseudo-element belongs to the frame we already mark, and the frame
carries only "position: relative" with z-index auto — no stacking context
— so this mask competes with the drawer in the parent stacking context
and, at 1050, paints over it. It covers the whole viewport, so every tap
on a session row lands on the mask instead: the drawer opens but nothing
inside it can be pressed, and the page behind cannot be scrolled.
Removing it is safe: the mobile stylesheet already renders its own
backdrop, and tapping outside the drawer is handled in JS.
The attribute selector is repeated on purpose. Their rule has the same
specificity (0,2,1) once ours is written the obvious way, and plugin
stylesheets are injected in load order, so a tie would be decided by
whichever plugin happened to load last. Doubling the attribute makes it
(0,3,1) and deterministic. */
[data-mobile-nav="frame"][data-mobile-nav="frame"]:not([data-sidebar-collapsed])::after {
content: 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 is the only _scroll element holding markdown
paragraphs — the composer's own scroll (textarea) is excluded
via :has(p). */
/* 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 !important;
}
[data-phase] [class$="_scrollBody"]::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
/* 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 !important;
}
[data-phase] [class$="_actions"] [class$="_timeEnd"] {
flex: 0 1 auto !important;
min-width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
[data-phase] [class$="_scroll"]:has(p) {
padding-left: 20px !important;
padding-right: 20px !important;
font-size: 15px !important;
}
/* The official markdown styles set an explicit 16px on paragraphs and
list items, so the container's inherited 15px is not enough. User
messages render their text in a div whose class carries _text_
(16px too) — cover it as well. */
[data-phase] [class$="_scroll"]:has(p) p,
[data-phase] [class$="_scroll"]:has(p) li,
[data-phase] [class$="_scroll"]:has(p) [class*="_text_"] {
font-size: 15px !important;
}
/* Keep DSH's own process disclosures and their expand/collapse behaviour,
but remove desktop-sized vertical breathing room between consecutive
context, Skill, and system-prompt entries. */
[data-phase] [data-turn-process] {
height: 28px !important;
padding-bottom: 4px !important;
margin-bottom: 4px !important;
}
[data-phase] [data-turn-process][data-open] {
margin-bottom: 4px !important;
}
[data-phase] [data-disclosure-row] {
min-height: 24px !important;
}
[data-phase] :is([data-context-injection-body], [data-system-prompt-body]) {
margin-top: 2px !important;
}
/* --- Composer bottom row on mobile ---
Keep add, permission, model, reasoning and send controls on one line at
the Honor 50's 360px CSS viewport. DSH's stable data-composer-card hook
survives the editor's textarea -> contenteditable migration. */
[data-phase] [data-composer-card="true"] > [class$="_row"] {
flex-wrap: nowrap !important;
gap: 6px !important;
}
[data-phase] [data-composer-card="true"] > [class$="_row"] > :first-child {
gap: 8px !important;
min-width: 0 !important;
}
[data-phase] [data-composer-card="true"] > [class$="_row"] > :first-child > :nth-child(2) {
flex: 0 1 auto !important;
min-width: 0 !important;
}
[data-phase] [data-composer-card="true"] > [class$="_row"] > :last-child {
flex: 1 1 0 !important;
min-width: 0 !important;
gap: 6px !important;
}
/* --- Composer popups as bottom sheets on mobile ---
Two composer-anchored popups break on phones (field: bottom + side
cut, only part of the popup visible):
1. the model pill menu ([role=menu], max 360px, opens upward from the
pill inside the composer card);
2. the "/" command palette (max 320px card with the search box — it
hosts the /model popupSelect list: search + provider-grouped rows).
Both are position:absolute INSIDE the conversation scrollBody
(overflow:hidden) and the shell center column (overflow:hidden), so
the scroll containers clip them mid-list. Forensics showed neither
layer creates a containing block (no transform/contain/will-change),
so on mobile we snap whichever popup is open to a viewport-anchored
sheet: fixed positioning escapes the scroll clip entirely, width is
deterministic, safe-area keeps it off the gesture bar. A transient
picker covering the composer is standard mobile UX; selection or an
outside tap still dismisses it. */
[data-phase] [class$="_root"]:has(> [aria-haspopup="menu"]) > [role="menu"],
[data-phase] [class$="_card"]:has(> [class$="_search"]) {
position: fixed !important;
left: 12px !important;
right: 12px !important;
top: auto !important;
bottom: calc(env(safe-area-inset-bottom, 0px) + 12px) !important;
width: auto !important;
min-width: 0 !important;
max-width: none !important;
max-height: min(65vh, 480px) !important;
max-height: min(65dvh, 480px) !important;
z-index: 130 !important;
border-radius: 14px !important;
box-shadow: 0 -4px 28px rgba(0, 0, 0, .18) !important;
}
/* --- Session header on mobile ---
Layout goal: [toggle] [session title] [mode badge] in a row, with the
Session log capsule removed from the header (relocated to the drawer
footer). Stable structural hooks only:
[data-phase] header the session header element
header > :first-child titleRow (titleCluster + utilities)
header > :first-child > :last-child headerUtilities (Session log seat) */
[data-phase] header {
padding: 8px 12px 0 !important;
}
/* The directory and Files controls are absolutely positioned, so reserve
their lanes and let the title use the remaining width without squeezing. */
[data-phase] header > :first-child {
min-height: 36px !important;
padding: 0 32px !important;
}
[data-phase] header [class$="_titleCluster"],
[data-phase] header [class$="_crumbs"] {
min-width: 0 !important;
}
[data-phase] header button[class*="_crumb"] {
max-width: calc(100vw - 104px) !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
/* The directory toggle sits at the far left of the header (the header
is position:relative; the data-slot wrappers are display:contents). */
[data-mobile-nav="toggle"] {
position: absolute !important;
left: 8px !important;
top: 12px !important;
z-index: 2 !important;
}
/* The Files action sits at the FAR RIGHT of the header so it reads as a
distinct control from the directory toggle on the left (which opens
the history sidebar). */
[data-mobile-nav="files"] {
position: absolute !important;
left: auto !important;
right: 8px !important;
top: 12px !important;
z-index: 2 !important;
}
/* Session log download: gone from the header row on mobile (the utilities
seat holds only the session-log-export capsule). */
[data-phase] header > :first-child > :last-child {
display: none !important;
}
/* --- Settings dialog on mobile ---
Desktop: 800px two-column flex (188px nav + content). Mobile: a
near-full-width sheet — nav tabs wrap into rows on top, option rows
stay horizontal (title+description left, control right). Structural
selectors are scoped to the unique aria-modal dialog; every
settings-specific rule is gated with
:has(> :first-child > :last-child > button) — the settings nav tab
list holds