Files
apes-sidebar/src/client/sidebar.module.css
T
Atlas Zeng 50a888845f feat(sidebar): 桌面兼容三方案——自动检测(WCO 标准几何)/壳预设/自定义 CSS (#284)
* feat(sidebar): 桌面兼容三方案——自动检测(WCO 标准几何)/壳预设/自定义 CSS

核心只保留 Web 标准机制,壳专属适配全部下沉到用户空间(KISS):
- prefs 新增 titleBarScheme(默认 auto)/titleBarPresetId/customCss,旧 titleBarCompat
  布尔自动迁移(true→custom 保留 stripPx,false/缺失→auto)
- wco.ts:navigator.windowControlsOverlay 反应式几何(geometrychange 实时更新;
  visible=false 的幽灵 API 视为缺失),实现 #257
- titlebar-strip.ts 唯一 strip 取值链:WCO → dsh-desktop-titlebar-inset 契约参数
  → 预设 stripFor → 手动 px → 0;desktop-env.ts 移除 win32 32px 硬编码
- shell-presets.ts 数据驱动预设(准入:issue/PR 提及 + 100+ star;v1 仅
  anywhere-labs DSH Desktop:darwin 20px / win32 无 WCO 时 32px),opt-in 手动
- 预设/自定义 CSS 注入(data-dsh-preset-css / data-dsh-custom-css 标签),
  chrome 补稳定寻址面 data-dsh-toggle-cluster / data-dsh-panel
- 交互 chrome 统一 -webkit-app-region: no-drag(吸收 #111/#153)
- layout.css 底栏推挤复合锚点([data-pane] 与 :has(> [data-slot]) 双保险,
  rc.8 实测前者失效、后者命中,吸收 #226 思路)
- 设置 UI:位置兼容模式齿轮弹窗改三方案(自动检测/壳兼容方案/自定义方案)
- 测试:wco/titlebar-strip/shell-presets 单测 + prefs 迁移 + e2e 5 场景
  (保守 auto/WCO 反应式/预设/自定义 CSS/复合锚点),挂载门禁 12/12 全绿

核心 grep 无壳专属类名;为某壳做的兼容在另一壳会再坏——预设与自定义 CSS
是唯一出路(AGENTS.md §8 契约已更新)。

* refine(sidebar): 位置兼容模式改主行下拉四选项——自动检测/DSH官方Web/壳预设/自定义

按用户反馈定稿 UI:
- 主行下拉:自动检测(默认)/ DSH官方Web(显式零适配,连 WCO 也强制 0)/
  各壳预设(命中环境带「已检测」后缀,仅提示)/ 自定义方案(保留齿轮弹窗:
  下移距离 + 自定义 CSS)
- 旧文档迁移:titleBarCompat=true 或 titleBarStripPx 非默认值(此前已有配置)
  → 默认落到 custom 方案保留用户数值;干净文档 → auto
- prefs 新增 'web' 方案;strip 取值链最优先处理 web→0
- 测试:prefs 迁移(stripPx 48→custom / 40→auto)、web 强制 0、下拉渲染断言
- 单测 693 通过,挂载 e2e 12/12 全绿

* refine(sidebar): 方案下拉复用 SelectMenu 组件,不用原生 <select>

- 抽出可复用 SelectMenu(primitives Menu:锚点按钮 + 下拉选项卡,支持
  icon/desc/多选),SelectRow 改为其薄壳
- 位置兼容模式方案下拉改用 SelectMenu;选项带描述(自动检测/DSH官方Web/
  各壳预设(命中带「已检测」后缀)/自定义方案)
- 移除原生 select 样式;单测 693 通过,挂载 e2e 12/12 全绿
2026-08-21 01:28:17 +08:00

2499 lines
61 KiB
CSS

/**
* Sidebar styles. Per the DSH web-styling rules every visual value rides the
* theme: --dsw-alias-* semantic tokens for colors, --dsw-font-* typography
* roles (size paired with line-height), and --ds-* for motion. The chrome
* mirrors the native DSH sidebar: flat (NO box-shadows anywhere), the
* sidebar fill surface, hairline borders, and the 28/36px circular icon
* controls with hover fills. No static palette values, no color literals, no
* theme selectors — light/dark adaptation is the theme package's job.
*/
/* ── Shell ─────────────────────────────────────────────────────────────── */
/* Skinning contract (token-driven): every surface here consumes DSH's
generic tokens (`--dsw-alias-*`), NEVER `--dsw-specific-sidebar-fill` —
skin systems override that token to restyle the APP's own left-nav column
(dsh-web-ui skins make it translucent glass or theme colors, Aqua makes it
transparent), and a panel consuming it would lose its fill or clash with
the label tokens. The panels use the generic card surface
`--dsw-alias-bg-layer-1`, which every dsh-web-ui skin overrides — so
switching skins in the dsh-web-ui skin center re-skins the sidebar panels
automatically (see AGENTS.md §8). */
/* The unified panel host: a fixed, viewport-sized, click-transparent
containing block for every panel element. Panels are ABSOLUTE within it
(not fixed), so a desktop shell's intermediate wrapper transform can
never hijack their containing block — the host itself is the only fixed
element, appended directly to document.body. The layer sits at z-index 40
(below the app's overlay stack at 100+); each panel re-enables pointer
events on itself. Degraded mode ([data-dsh-panel-host-degraded]) covers
the rare shell that transforms <html>/<body> itself: the host becomes
absolute and the mount self-check pins it to the viewport every frame. */
:global([data-dsh-panel-host]) {
position: fixed;
inset: 0;
z-index: 40;
pointer-events: none;
}
:global([data-dsh-panel-host][data-dsh-panel-host-degraded]) {
position: absolute;
top: 0;
left: 0;
}
/* The persistent expand/collapse cluster at the top-right corner: two rail
controls side by side (bottom panel's glyph LEFT of the right panel's).
Always pinned to the viewport corner — inside the right panel's top-right
while it is open, sitting flush in the 34px tab strip (28px buttons at
top: 3) whose right end it really squeezes. z-index 45: above the panels
(40) but below the app's overlay stack (menus/tooltips/modals sit at
100+), so a cordis approval popup is never hidden behind the workbench
(issue #52). Absolute within the panel host (the host's inset: 0 makes
viewport coordinates identical). */
.toggleCluster {
position: absolute;
/* env() resolves to 0 on devices without a top inset (desktops). */
top: calc(3px + env(safe-area-inset-top));
right: 10px;
z-index: 45;
display: flex;
flex-direction: row;
gap: 4px;
pointer-events: auto;
}
/* The tab strip of the OPEN right panel reserves the cluster's width at its
right end, so the tabs and the + menu genuinely yield space to the cluster
(never hiding under it). */
.panel:not(.panelHidden) .tabBar {
padding-right: 72px;
}
/* The closed-state affordance is the native rail icon control: a plain
28px circle (the app's icon-button size — the cluster must stay inside
the 34px tab-strip band so it never covers the pane headers below),
transparent fill, secondary ink; hover raises the fill. No border, no
shadow — the icon alone is the affordance. */
.toggleButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
transition:
background var(--ds-transition-duration-slow) var(--ds-ease-in-out),
color var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.toggleButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.toggleButton:disabled {
opacity: 0.4;
cursor: default;
}
.panel {
position: absolute;
top: 0;
right: 0;
/* Full height: the bottom panel squeezes only the center column, so the
right panel never gives up any vertical position. */
bottom: 0;
/* z-index 40: above the app's in-flow content, below the app's overlay
stack (100+), so menus/tooltips/modals always win over the panels
(issue #52). The toggle cluster (45) sits above it; the corner handle
is a child and stacks within the panel. */
z-index: 40;
display: flex;
flex-direction: column;
/* Re-enable interaction inside the click-transparent panel host. */
pointer-events: auto;
/* The generic card surface (see the root contract above). Never
`--dsw-specific-sidebar-fill` — skin plugins restyle that token for the
app's own left nav and a panel consuming it loses its fill or clashes
with labels (#60/#105/#106). */
background: var(--dsw-alias-bg-layer-1);
border-left: 1px solid var(--dsw-alias-border-l2);
/* Bottom safe-area (notched phones): the drawer's bottom content clears
the home indicator; 0 on desktops. */
padding-bottom: env(safe-area-inset-bottom);
/* Expand/collapse slides the panel; fullscreen animates the width. */
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Collapsed: slid off-screen, interactive nothing, hidden after the slide. */
.panelHidden {
transform: translateX(102%);
pointer-events: none;
visibility: hidden;
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
width var(--ds-transition-duration-slow) var(--ds-ease-in-out),
visibility 0s linear var(--ds-transition-duration-slow);
}
/* Width drags write at pointer cadence; easing would detach the edge. */
.panel[data-dragging] {
transition: none;
}
.panelResize {
position: absolute;
/* Centered on the panel edge (an 8px hit strip straddling the border, the
same pattern as the host's own frame handles). */
left: -4px;
top: 0;
bottom: 0;
width: 8px;
cursor: col-resize;
z-index: 2;
touch-action: none;
}
.panelResizeActive {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
.panelBody {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
}
/* ── Bottom panel ──────────────────────────────────────────────────────── */
/* The bottom panel: an independent workbench at the bottom of the CENTER
column only (the Sidebar shell writes left/right inline: from the app's
own left sidebar to the right panel's left edge) — the sidebars never
give up any position. Same surface as the right panel, with a top
hairline instead of the left one. z-index 40 mirrors the right panel
(issue #52: below the app's overlay stack so popups win over the
workbench). */
.bottomPanel {
position: absolute;
bottom: 0;
z-index: 40;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-layer-1);
border-top: 1px solid var(--dsw-alias-border-l2);
/* Re-enable interaction inside the click-transparent panel host. */
pointer-events: auto;
/* Bottom safe-area (notched phones); 0 on desktops. */
padding-bottom: env(safe-area-inset-bottom);
/* Expand/collapse slides the panel; height drags animate the height. */
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
height var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Collapsed: slid off-screen, interactive nothing, hidden after the slide. */
.bottomPanelHidden {
transform: translateY(102%);
pointer-events: none;
visibility: hidden;
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
visibility 0s linear var(--ds-transition-duration-slow);
}
/* Height drags write at pointer cadence; easing would detach the edge. */
.bottomPanel[data-dragging] {
transition: none;
}
/* Layout isolation: panel-internal reflows (tabs, editors, terminals) never
leak into the app shell's layout, and the app shell's layout changes
never cascade through the panel subtree — the drag stays confined.
will-change: transform is only live while a drag is (the body attribute
toggles with the drag state), promoting the panels to their own layer
without paying the compositing cost at rest. */
.panel,
.bottomPanel {
contain: layout style;
}
body[data-dsh-sidebar-dragging] .panel,
body[data-dsh-sidebar-dragging] .bottomPanel {
will-change: transform;
}
/* The bottom panel's height drag: the top edge strip (mirror of the right
panel's width strip), centered on the border like the width strip. */
.bottomResize {
position: absolute;
left: 0;
right: 0;
top: -4px;
height: 8px;
cursor: row-resize;
z-index: 2;
touch-action: none;
}
.bottomResizeActive {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
/* The bottom panel's close control: the app's icon-button pattern (28px
circle), pinned to the tab strip's right end — one tap collapses the
panel. The tab strips below reserve its width so the + menu never hides
under it. */
.bottomClose {
position: absolute;
top: 3px;
right: 6px;
z-index: 4;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
flex: none;
}
.bottomClose:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* The bottom panel's tab strips reserve the close control's width at their
right end, so the + menu never hides under it. */
.bottomPanel .tabBar {
padding-right: 40px;
}
/* ── Desktop drag-region opt-out ──────────────────────────────────────── */
/* Frameless desktop shells make the top strip of the web content draggable
(Electron `-webkit-app-region: drag`); pointer events over that strip are
swallowed as window drags unless the interactive element opts out
(issues #103/#111). The plugin's viewport-corner chrome must declare
`no-drag` itself — some shells (Tauri, older desktop builds, third-party
wrappers) have no blanket button rule. The property is a no-op in plain
browsers and in shells without drag regions — safe everywhere. */
.toggleCluster,
.toggleButton,
.tabBar {
-webkit-app-region: no-drag;
}
/* ── Position compatibility mode (Windows native title bar) ────────────── */
/* Windows frameless / hidden-title-bar windows draw the native caption
strip (minimize / maximize / close) at the window's top-right corner,
OVER the web content. When the user enables the "position compatibility"
pref, the sidebar yields the strip: the toggle cluster drops below it and
the right panel's content starts below it — the panel's surface still
spans the full height, so the caption buttons float over empty sidebar
fill (the VSCode hidden-title-bar look). The strip height is user-tunable
(`--dsh-title-bar-strip`, written by the Sidebar shell from
`titleBarStripPx`; 40px fallback). The bottom panel is unaffected: it
sits at the window's bottom, never under the top strip. */
:global(body[data-dsh-title-bar-compat]) .toggleCluster {
top: calc(var(--dsh-title-bar-strip, 40px) + 3px);
}
:global(body[data-dsh-title-bar-compat]) .panel {
/* The caption strip is drawn over the panel's own top edge: keep the
surface full-height and push the content (the tab strip) below the
strip instead. .panelResize is absolutely positioned and unaffected. */
padding-top: var(--dsh-title-bar-strip, 40px);
}
/* The shared corner (both panels open): the intersection of the right
panel's left edge and the bottom panel's top edge. Horizontal drags
resize the width, vertical drags the height — the two panels drag
against each other. Rendered INSIDE the right panel and positioned
relative to it (left edge + the bottom panel's height from the layout
variable), so no JS-written viewport coordinates are involved. The
z-index is panel-local (above the panel's content; the panel itself sits
at 40, below the app's overlay stack). */
.cornerHandle {
position: absolute;
left: -6px;
bottom: calc(var(--dsh-sidebar-height, 0px) + 6px);
width: 12px;
height: 12px;
z-index: 2;
cursor: nwse-resize;
touch-action: none;
}
.cornerHandle:hover,
.cornerHandle[data-dragging] {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
/* Icon button: the app's own icon-button pattern (ui-workspace) — a 28px
circle, secondary label, hover fill; no border so the icon alone is the
affordance. */
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
flex: none;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:disabled {
opacity: 0.4;
cursor: default;
}
/* ── Workbench / split panes ───────────────────────────────────────────── */
.workbench {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
}
.split {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
}
.splitRow {
flex-direction: row;
}
.splitCol {
flex-direction: column;
}
.splitChild {
position: relative;
display: flex;
overflow: hidden;
}
.divider {
position: relative;
flex: none;
z-index: 3;
touch-action: none;
}
/* The hit area stays 7px wide enough to grab, but the visual is a 1px
hairline centered in it — a divider line, not a thick band. */
.dividerRow::after,
.dividerCol::after {
content: '';
position: absolute;
background: var(--dsw-alias-border-l2);
transition: background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.dividerRow {
width: 7px;
margin: 0 -2px;
cursor: col-resize;
}
.dividerRow::after {
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
}
.dividerCol {
height: 7px;
margin: -2px 0;
cursor: row-resize;
}
.dividerCol::after {
left: 0;
right: 0;
top: 50%;
height: 1px;
transform: translateY(-50%);
}
.divider:hover::after,
.dividerActive::after {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
.pane {
position: relative;
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-base);
}
.paneDrop {
outline: 1px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
/* VSCode-style drag-to-edge drop zones (25% edges, 50% center). */
.dropOverlay {
position: absolute;
z-index: 6;
pointer-events: none;
background: var(--dsw-alias-interactive-bg-hover-accent);
opacity: 0.5;
}
.dropLeft {
left: 0;
top: 0;
bottom: 0;
width: 25%;
}
.dropRight {
right: 0;
top: 0;
bottom: 0;
width: 25%;
}
.dropUp {
top: 0;
left: 0;
right: 0;
height: 25%;
}
.dropDown {
bottom: 0;
left: 0;
right: 0;
height: 25%;
}
.dropCenter {
left: 25%;
right: 25%;
top: 25%;
bottom: 25%;
background: transparent;
outline: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -2px;
}
.paneContent {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* One tab's mounted content; inactive tabs stay mounted but hidden so
switching never tears down terminals/editors (state survives). */
.paneTab {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.paneTabHidden {
display: none;
}
/* An empty pane's welcome cards: a responsive grid of compact cards — as
many per row as the pane width allows; rows that cannot fit the pane
height are clipped (never scrolled or squeezed smaller). */
.paneEmptyCards {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
align-content: start;
gap: 8px;
padding: 12px;
overflow: hidden;
}
.paneCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
padding: 12px 8px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
text-align: center;
}
.paneCard:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
border-color: var(--dsw-alias-border-l2);
}
.paneCard:disabled {
opacity: 0.45;
cursor: default;
}
/* ── Tab strip ─────────────────────────────────────────────────────────── */
.tabBar {
flex: none;
display: flex;
align-items: stretch;
height: 34px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-1);
}
.tabBarDrop {
outline: 1px dashed var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
.tabList {
flex: 1;
min-width: 0;
display: flex;
overflow-x: auto;
scrollbar-width: none;
}
.tabList::-webkit-scrollbar {
display: none;
}
.tab {
flex: none;
max-width: 160px;
min-width: 64px;
display: flex;
align-items: center;
gap: 4px;
padding: 0 4px 0 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
border-right: 1px solid var(--dsw-alias-border-l1);
background: transparent;
cursor: pointer;
user-select: none;
}
.tab:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.tabActive {
color: var(--dsw-alias-label-primary);
/* Native selected fill (Rows .sessionRow.selected) — no underline, no shadow. */
background: var(--dsw-alias-interactive-bg-active);
}
.tabTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* One tab's descriptor badge (a count capped at 99+, or a short text). */
.tabBadge {
flex: none;
min-width: 16px;
height: 15px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font: var(--dsw-font-xxxs-strong-11);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-brand-primary);
}
.tabClose {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
flex: none;
}
.tabClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.tabBarPlus {
flex: none;
align-self: center;
/* Adjacent to the rightmost tab; sticky so overflowing tab rows keep it
visible at the right edge of the scrollport. */
position: sticky;
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin: 0 6px;
padding: 0;
border: none;
border-radius: 5px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.tabBarPlus:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* ── Explorer ──────────────────────────────────────────────────────────── */
.explorer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.explorerHeader {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
}
.explorerRoot {
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.explorerBody {
flex: 1;
min-height: 0;
overflow-y: auto;
/* Deep trees truncate their labels instead of scrolling horizontally. */
overflow-x: hidden;
padding: 2px 6px 8px;
}
/* One tree row: the app's session-row cell (34px, radius 8, inset, hover
fill, 22px indent step, 14px ink) with a mount fade so expanding a
directory reveals its children gently (stable keys keep existing rows from
replaying it). */
.explorerRow {
display: flex;
align-items: center;
gap: 6px;
/* border-box keeps the inline indent padding (depth * 22 + 6) INSIDE the
row's 100% width — without it deep rows overflow and force a horizontal
scrollbar on the tree body. */
box-sizing: border-box;
width: 100%;
max-width: 100%;
height: 34px;
padding: 0 8px;
border: none;
background: transparent;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
white-space: nowrap;
border-radius: 8px;
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
.explorerRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.explorerDir {
font: var(--dsw-font-s-strong-14);
}
.explorerHidden {
opacity: 0.45;
}
/* The link badge on symlink rows (folder and file rows alike). */
.explorerSymlink {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
/* A symlink whose target is missing: error ink on the name (the row stays
clickable — the editor surfaces the read failure). */
.explorerBroken .explorerName {
color: var(--dsw-alias-state-error-primary);
}
.explorerName {
/* min-width: 0 lets the flex item shrink below its content so the label
truncates with an ellipsis instead of widening the row. */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The hover-revealed @-reference button at the row's far right (and the
transient "copied" label that replaces it after a copy). Hidden rows
keep the name at full width; hovering or focusing the row reveals it. */
.explorerRef {
flex: none;
display: none;
align-items: center;
height: 20px;
padding: 0 8px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 999px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-strong-11);
cursor: pointer;
}
.explorerRef:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.explorerRow:hover .explorerRef,
.explorerRow:focus-within .explorerRef {
display: inline-flex;
}
.explorerCopied {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.explorerError {
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
cursor: default;
}
/* Mount fade for tree rows (the app's session-row reveal). */
@keyframes dsh-row-in {
from { opacity: 0; }
}
.explorerEmpty {
padding: 16px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
text-align: center;
}
/* Drag a file/folder over a tree row: the target directory row gets a filled
highlight (drops land inside it; file rows target their parent directory).
The zone frame around the whole tree is the portaled .uploadDropZone. */
.explorerRowDropTarget {
background: var(--dsw-alias-interactive-bg-hover);
outline: 1px dashed var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
/* The sidebar's drop surface: a pointer-inert frame portaled to
document.body, fixed over the explorer body's viewport rect at z-1001 —
above DSH's own whole-page drop mask (z-1000, see InputBar's
document-level intake). The giant box-shadow spread IS the full-viewport
mask (same token DSH uses for its drop mask): everything dims except the
zone rect, teaching the zone split — the dimmed conversation column still
takes drops into the chat natively, the clear frame marks the tree as the
workspace-upload zone. Deliberate exception to the "panel stays below the
DSH float stack" rule: transient and pointer-events none, so the drop
always lands on the row beneath. The hint pill docks at the TOP edge of
the zone (right under the search row, the first thing the eye meets),
leaving the rows — the actual drop targets — aimable. */
.uploadDropZone {
position: fixed;
z-index: 1001;
pointer-events: none;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 12px;
border: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
border-radius: 10px;
box-shadow: 0 0 0 200vmax var(--dsw-alias-bg-mask-drop);
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
/* The zone's drop hero, docked at the TOP edge (right under the search
row, the first thing the eye meets): the colorful tray illustration over
the hint pill — stacked so the rows below stay aimable. */
.uploadDropHero {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
max-width: 100%;
padding-top: 8px;
}
.uploadDropZonePill {
display: flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 6px 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-strong-12);
}
.uploadDropZoneText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* The left zone's invitation: centered in the viewport space beside the
tree (inline width = the zone's left edge), telling the user the dimmed
conversation column still accepts drops — into the chat, natively. */
.uploadDropChatHint {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 1002;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
/* The invitation's content: the colorful photo-cards illustration over the
label — the drop overlay is this flow's one brand moment, so it gets the
color the rest of the UI never does (palette borrowed from DSH's native
drop illustration so the two zones read as one family). */
.uploadDropChatCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 100%;
text-align: center;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-s-strong-14);
}
/* Full-window upload progress: blurred scrim + card (same mask/backdrop
tokens as the repo's Modal primitive; absolute within the tree panel so it
never covers the conversation column). */
.uploadOverlay {
position: absolute;
inset: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: center;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
.uploadOverlayCard {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 280px;
max-width: min(420px, calc(100% - 48px));
padding: 20px 24px;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 24px;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3);
}
.uploadOverlayTitle {
display: flex;
align-items: center;
gap: 8px;
font: var(--dsw-font-s-strong-14);
color: var(--dsw-alias-label-primary);
}
.uploadOverlayTitle > svg {
flex: none;
}
.uploadOverlayTitle > span {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.uploadOverlayProgress {
height: 6px;
border-radius: 3px;
background: var(--dsw-alias-border-l2);
overflow: hidden;
}
.uploadOverlayProgressFill {
height: 100%;
border-radius: 3px;
background: var(--dsw-alias-interactive-bg-hover-accent);
transition: width 150ms var(--ds-ease-in-out);
}
.uploadOverlayStatus {
min-height: 1em;
font: var(--dsw-font-xxs-12);
font-variant-numeric: tabular-nums;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.uploadOverlayCancel {
align-self: flex-end;
height: 28px;
padding: 0 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
}
.uploadOverlayCancel:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-border-l2);
}
.uploadOverlayCancel:disabled {
opacity: 0.4;
cursor: default;
}
/* ── Editor ────────────────────────────────────────────────────────────── */
.editor {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.editorHeader {
flex: none;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.editorTitle {
flex: 1;
min-width: 0;
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Merged mode (the editorExplorer pref): the header's path input replaces
the plain title, and a toggleable file tree docks at the tab's right edge. */
.editorPathInput {
flex: 1;
min-width: 0;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.editorPathInput:focus {
outline: none;
border-color: var(--dsw-alias-border-l2);
}
/* The tree-panel toggle in the header: active = panel open (the selected
fill of the tab strip). */
.editorTreeToggleActive {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-active);
}
.editorBody {
flex: 1;
min-height: 0;
display: flex;
}
.editorMain {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
/* The docked file tree: the wrapper owns the (drag-adjustable) width and
the hairline separation; the resize handle is a wider invisible strip on
its left edge with a hover affordance. */
.editorTreeDock {
position: relative;
flex: none;
min-height: 0;
display: flex;
border-left: 1px solid var(--dsw-alias-border-l1);
}
.editorTreeResize {
position: absolute;
top: 0;
bottom: 0;
/* Inside the dock (left edge): pointer capture on the handle keeps the
drag alive even when the pointer leaves the strip. */
left: 0;
width: 6px;
cursor: col-resize;
touch-action: none;
z-index: 3;
}
.editorTreeResize:hover {
background: var(--dsw-alias-border-l2);
}
.editorTreePanel {
/* Relative so the upload overlay and drop pill can cover the tree exactly. */
position: relative;
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
/* The tree panel's full-window form (the `full` prop): fill the tab body. */
.editorTreePanelFull {
flex: 1;
}
.editorTreeSearch {
flex: none;
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.editorSearchInput {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.editorSearchInput:focus {
outline: none;
border-color: var(--dsw-alias-border-l2);
}
.editorSearchHint {
padding: 8px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.editorSearchResult {
display: block;
width: 100%;
padding: 4px 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editorSearchResult:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.editorStatus {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.editorStatusError {
color: var(--dsw-alias-state-error-primary);
}
.dirtyDot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--dsw-alias-state-warn-primary);
flex: none;
}
.editorPlaceholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
padding: 16px;
text-align: center;
}
/* The unregistered tab type shown by the orphaned-tab placeholder. */
.orphanedType {
display: block;
margin-top: 8px;
font-size: 12px;
opacity: 0.7;
overflow-wrap: anywhere;
}
/* Binary / download-only files (legacy OLE .doc/.xls/.ppt, unknown binaries):
a centered notice plus a download link — the user always gets the file. */
.editorBinary {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px 16px;
text-align: center;
}
.editorBinaryNotice {
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.editorDownloadLink {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-strong-12);
text-decoration: none;
cursor: pointer;
transition:
background var(--ds-transition-duration-slow) var(--ds-ease-in-out),
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.editorDownloadLink:hover {
background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-border-l2);
}
.editorError {
padding: 12px 16px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
}
.editorBanner {
flex: none;
padding: 4px 12px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
/* Live sandbox status row (HTML preview / browser tab): GREEN while the
sandbox is on (with the one-tap temporary unlock), RED while it is off
(global setting or temporary unlock) — the red state is the persistent
warning that the page runs on the GUI's own origin. */
.sandboxStatus {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
font: var(--dsw-font-xxxs-11);
}
.sandboxStatusOn {
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.sandboxStatusOff {
color: var(--dsw-alias-state-error-primary);
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
}
.sandboxDot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--dsw-alias-state-success-primary);
}
.sandboxStatusOff .sandboxDot {
background: var(--dsw-alias-state-error-primary);
}
.sandboxStatusText {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sandboxAction {
flex: none;
padding: 2px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: transparent;
font: inherit;
color: inherit;
cursor: pointer;
}
.sandboxAction:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* The HTML preview iframe: fills the editor body. Route-src (never srcdoc)
so the frame is cross-origin by construction even if the sandbox
attribute is ever dropped; sandboxed by default (see TextEditor). */
.editorHtml {
flex: 1;
min-height: 0;
width: 100%;
border: none;
background: var(--dsw-alias-bg-base);
}
/* ── Browser tab ──────────────────────────────────────────────────────── */
.browser {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.browserBar {
flex: none;
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.browserInput {
flex: 1;
min-width: 0;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.browserInput:focus {
outline: none;
border-color: var(--dsw-alias-border-l2);
}
.browserMessage {
flex: none;
padding: 4px 12px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.browserFrame {
flex: 1;
min-height: 0;
width: 100%;
border: none;
background: var(--dsw-alias-bg-base);
}
.browserStart {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
text-align: center;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-tertiary);
}
/* Embed-refusal panel (X-Frame-Options / frame-ancestors): replaces the
blank refused iframe with the reason + open-in-browser actions. */
.browserBlocked {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 24px;
text-align: center;
color: var(--dsw-alias-state-warn-primary);
}
.browserBlockedTitle {
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
}
.browserBlockedDesc {
max-width: 280px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-secondary);
}
.browserBlockedActions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.browserBlockedButton {
padding: 4px 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxxs-11);
cursor: pointer;
}
.browserBlockedButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.editorCm {
flex: 1;
min-height: 0;
overflow: hidden;
background: transparent;
}
.editorCmHidden {
display: none;
}
/* CodeMirror fills the pane; the theme supplies its own token palette
(a designed dark theme, same policy as the xterm palette). */
.editorCm :global(.cm-editor) {
height: 100%;
}
.editorCm :global(.cm-editor.cm-focused) {
outline: none;
}
/* Preview / edit segmented toggle (markdown and other previewable files). */
.editorModeToggle {
flex: none;
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
}
.editorModeButton {
padding: 2px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-11);
cursor: pointer;
}
.editorModeButton:hover {
color: var(--dsw-alias-label-primary);
}
.editorModeActive {
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
}
.editorImageWrap {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
padding: 12px;
}
.editorImage {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.editorMd {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px 14px;
font: var(--dsw-font-xs-13);
}
/* ── Mermaid diagram block (markdown preview, mermaid lazy chunk) ────────
Mirrors the md-code-block chrome the DSH CodeBlock renders, so a diagram
reads as a peer of the surrounding fences: hairline border, layer-2
header with the info string + copy action, layer-1 body. The SVG body is
static (no bindFunctions) and sanitized (no foreignObject/script/@attrs/
hrefs), so it carries no event surface beyond the copy button; the body
click opens the zoom modal on a sanitized clone. */
.mermaidWrap {
margin: 6px 0;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
overflow: hidden;
}
.mermaidHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-2);
}
.mermaidInfo {
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-tertiary);
}
.mermaidCopy {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 6px;
height: 20px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxxs-11);
cursor: pointer;
}
.mermaidCopy:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.mermaidBody {
display: flex;
justify-content: center;
padding: 10px;
overflow: auto;
cursor: zoom-in;
}
.mermaidBody :global(svg) {
max-width: 100%;
height: auto;
}
.mermaidError {
padding: 6px 10px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
color: var(--dsw-alias-state-error-primary);
font: var(--dsw-font-xxxs-11);
}
/* The diagram source shown under a render error (helps fixing the fence). */
.mermaidCode {
margin: 0;
padding: 8px 10px;
overflow: auto;
font: var(--dsw-font-xxxs-11);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* The mermaid markdown container (single MarkdownText pass): a swapped
code block keeps its React-managed .md-code-block wrapper in the tree
(reconciliation safety), but the wrapper's own box is suppressed so only
the diagram chrome renders; the swapped-in host div flows as a block. */
.mermaidMarkdown :global(.md-code-block[data-mermaid-processed]) {
display: contents;
}
/* ── Mermaid zoom modal (click-to-enlarge; portalled to document.body) ──
Fixed overlay above the DSH overlay stack (1000+), token-driven surface.
The stage centers the (sanitized, cloned) svg and the modal owns the
transform; the drag/zoom listeners live in the component, nothing here
targets global class names. */
.mermaidModal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: blur(2px);
}
.mermaidModalToolbar {
position: absolute;
top: 16px;
right: 16px;
display: flex;
gap: 8px;
z-index: 10;
}
.mermaidModalButton {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xs-strong-13);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mermaidModalButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.mermaidModalStage {
width: 90vw;
height: 80vh;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.mermaidModalStage :global(svg) {
max-width: none;
max-height: none;
cursor: grab;
transform-origin: center center;
user-select: none;
-webkit-user-drag: none;
background: var(--dsw-alias-bg-layer-1);
padding: 16px;
border-radius: 12px;
}
.mermaidModalStage :global(svg):active {
cursor: grabbing;
}
.mermaidModalHint {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-11);
pointer-events: none;
}
/* The floating "add to conversation" button anchored above a text
selection in the preview/code viewers. Portalled to document.body and
position:fixed, so the editor's overflow clips cannot crop it; flat
chrome like the rest of the sidebar (hairline border, layer-2 fill,
hover fill — no shadow). */
.selectionPopup {
position: fixed;
z-index: 60;
transform: translate(-50%, calc(-100% - 8px));
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxxs-strong-11);
white-space: nowrap;
cursor: pointer;
}
.selectionPopup:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.editorPdf {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-base);
}
.editorPdfToolbar {
flex: none;
display: flex;
justify-content: flex-end;
padding: 6px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.editorPdfStage {
position: relative;
flex: 1;
min-height: 0;
display: flex;
}
.editorPdfFrame {
flex: 1;
min-height: 0;
width: 100%;
border: none;
background: var(--dsw-alias-bg-base);
}
.editorPdfFrameBlocked {
pointer-events: none;
}
.editorPdfDragShield {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
background: transparent;
}
.editorPdfDragShieldActive {
pointer-events: auto;
}
/* Tab drag must switch iframe hit-testing before the pointer can enter the
Chromium PDF browsing context. The body flag is set synchronously by the
drag source, avoiding React render timing entirely. */
:global(body[data-dsh-tab-dragging]) .editorPdfFrame {
pointer-events: none !important;
}
:global(body[data-dsh-tab-dragging]) .editorPdfDragShield {
pointer-events: auto !important;
}
/* ── Terminal ──────────────────────────────────────────────────────────── */
.terminalWrap {
flex: 1;
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-base);
}
.terminal {
flex: 1;
min-height: 0;
padding: 6px 4px 6px 8px;
}
.terminal :global(.xterm) {
height: 100%;
}
.terminalBanner {
flex: none;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 3px 10px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
/* The attempted ws URL, shown when the connection failed (diagnosis). */
.terminalBannerUrl {
flex-basis: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
word-break: break-all;
opacity: 0.85;
}
.boundaryError {
position: fixed;
right: 0;
top: 0;
bottom: 0;
z-index: 50;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
padding: 16px;
background: var(--dsw-alias-bg-layer-1);
border-left: 1px solid var(--dsw-alias-border-l2);
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
overflow: auto;
}
.terminalRetry {
flex: none;
padding: 1px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxxs-strong-11);
cursor: pointer;
}
.terminalRetry:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* The node-pty dependency failure banner (issue #140): a taller warn block
carrying the pasteable repair command (bash / cmd / PowerShell). */
.terminalDepsBanner {
flex: none;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.terminalDepsTitle {
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-state-warn-primary);
}
.terminalDepsHint {
opacity: 0.9;
}
.terminalDepsCommandRow {
display: flex;
align-items: flex-start;
gap: 8px;
}
/* The command the user pastes into a terminal / cmd. Selectable, wrapping,
and scrollable when long (Windows paths can get very long). */
.terminalRepairCommand {
flex: 1;
min-width: 0;
margin: 0;
padding: 6px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
user-select: text;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-2);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
max-height: 160px;
overflow: auto;
}
.terminalDepsNote {
opacity: 0.85;
}
.terminalDepsActions {
display: flex;
align-items: center;
gap: 8px;
}
/* The PER-TAB error strip: fills one tab's content area (unlike
.boundaryError, the full-height fixed rail for shell-level crashes), so a
crashing viewer/editor never hides the toggle cluster or the other tabs. */
.tabBoundaryError {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
padding: 12px 16px;
overflow: auto;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
}
/* ── Git ───────────────────────────────────────────────────────────────── */
.git {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
/* Tab pages never scroll horizontally: content wraps or truncates to the
panel width instead of panning sideways. */
overflow-x: hidden;
}
.gitHeader {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
}
.gitBranchSelect {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 6px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.gitSection {
border-top: 1px solid var(--dsw-alias-border-l1);
}
.gitSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px 4px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-tertiary);
text-transform: uppercase;
}
.gitLink {
border: none;
background: transparent;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-brand-primary);
cursor: pointer;
padding: 0;
}
.gitLink:hover:not(:disabled) {
text-decoration: underline;
}
.gitLink:disabled {
opacity: 0.4;
cursor: default;
}
.gitRow {
display: flex;
align-items: center;
gap: 6px;
min-height: 34px;
margin: 0 6px;
padding: 0 8px;
border-radius: 8px;
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
.gitRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitRowSelected {
background: var(--dsw-alias-interactive-bg-active);
}
.gitRowMain {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
padding: 3px 0;
cursor: pointer;
text-align: left;
}
.gitBadge {
flex: none;
width: 20px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font: var(--dsw-font-xxxs-strong-11);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.gitName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitEmpty {
padding: 4px 12px 8px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.gitPlaceholder {
padding: 16px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
text-align: center;
}
.gitError {
padding: 8px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
white-space: pre-wrap;
}
.gitDiff {
border-top: 1px solid var(--dsw-alias-border-l1);
padding: 8px;
}
/* The diff tab (a change opened from the git panel, like VSCode's diff
editor): header + scrollable diff body, no horizontal panning. */
.gitDiffTab {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
.gitDiffTabHeader {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.gitDiffTabTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
}
/* One changed file's section: path header, hunk headers, aligned lines. */
.gitDiffFile {
display: flex;
align-items: baseline;
gap: 6px;
padding: 8px 2px 2px;
}
.gitDiffFilePath {
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gitDiffFileOld {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 40%;
}
.gitDiffFileTag {
flex: none;
padding: 0 6px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-secondary);
}
.gitDiffHunk {
display: flex;
gap: 8px;
padding: 3px 2px;
font: var(--dsw-font-markdown-code-block-small);
color: var(--dsw-alias-label-tertiary);
}
.gitDiffHunkHeader {
flex: none;
color: var(--dsw-alias-label-secondary);
}
.gitDiffHunkSection {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* One aligned diff line: old gutter / new gutter / code. Lines WRAP instead
of scrolling horizontally (the tab-page rule: fill the width adaptively),
with the long-token fallback breaking anywhere a space never comes. */
.gitDiffLine {
display: flex;
align-items: stretch;
font: var(--dsw-font-markdown-code-block-small);
line-height: 20px;
white-space: pre-wrap;
overflow-wrap: anywhere;
min-width: 0;
}
.gitDiffNum {
flex: none;
width: 36px;
padding-right: 8px;
text-align: right;
color: var(--dsw-alias-label-tertiary);
user-select: none;
}
.gitDiffCode {
flex: 1;
min-width: 0;
overflow: visible;
}
.gitDiffCtx {
color: var(--dsw-alias-label-primary);
}
.gitDiffDel {
color: var(--dsw-alias-state-error-primary);
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
}
.gitDiffAdd {
color: var(--dsw-alias-state-success-primary);
background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent);
}
.gitDiffMeta {
padding-left: 2px;
}
.gitDiffMetaText {
font: var(--dsw-font-xxxs-11);
font-style: italic;
color: var(--dsw-alias-label-tertiary);
}
.gitDiffExpand {
display: block;
width: 100%;
margin: 4px 0;
border: none;
background: transparent;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-brand-primary);
cursor: pointer;
text-align: center;
}
.gitDiffExpand:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitConfirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
}
.gitCommit {
display: flex;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--dsw-alias-border-l1);
align-items: center;
}
.gitCommitInput {
flex: 1;
min-width: 0;
}
.gitCommitButton {
flex: none;
height: 26px;
padding: 0 12px;
border: none;
border-radius: 6px;
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-inverted);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
}
.gitCommitButton:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.gitCommitButton:disabled {
opacity: 0.45;
cursor: default;
}
/* A history row: line 1 = hash + subject, line 2 = ref badges + author/date
(badges live on the second line so they never crowd the subject). */
.gitLogRow {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 12px;
cursor: pointer;
border-radius: 8px;
}
.gitLogRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitLogLine1 {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.gitLogHash {
flex: none;
font: var(--dsw-font-markdown-code-block-small);
color: var(--dsw-alias-label-tertiary);
}
.gitLogLine2 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.gitLogRef {
flex: none;
padding: 0 5px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-brand-primary);
white-space: nowrap;
}
.gitLogSubject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitLogMeta {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.gitLogMore {
display: block;
width: calc(100% - 24px);
margin: 4px 12px 8px;
padding: 6px 0;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: transparent;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.gitLogMore:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.gitLogMore:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Intercepted produced-files row ────────────────────────────────────── */
.producedRow {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 4px 0;
}
.producedLabel {
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.producedChip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 200px;
padding: 2px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-12);
cursor: pointer;
overflow: hidden;
}
.producedChip:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.producedChip span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.producedMore {
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
/* ── Shared interaction rules ──────────────────────────────────────────── */
/* Keyboard focus stays visible on every interactive control (the styling
policy requires focus visibility; the accent token doubles as the ring). */
.toggleButton:focus-visible,
.bottomClose:focus-visible,
.iconButton:focus-visible,
.tab:focus-visible,
.tabClose:focus-visible,
.tabBarPlus:focus-visible,
.paneCard:focus-visible,
.explorerRow:focus-visible,
.explorerRef:focus-visible,
.gitRowMain:focus-visible,
.gitLink:focus-visible,
.gitCommitButton:focus-visible,
.gitLogRow:focus-visible,
.gitLogMore:focus-visible,
.gitDiffExpand:focus-visible,
.terminalRetry:focus-visible,
.editorModeButton:focus-visible,
.editorDownloadLink:focus-visible,
.editorPptxButton:focus-visible,
.editorDocxZoomRange:focus-visible {
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
/* Reduced motion: no slides, fades or color transitions. */
@media (prefers-reduced-motion: reduce) {
.panel,
.panelHidden,
.bottomPanel,
.bottomPanelHidden,
.toggleCluster,
.toggleButton,
.tab,
.tabBarPlus,
.paneCard,
.explorerRow,
.gitRow,
.divider,
.dividerRow::after,
.dividerCol::after {
transition: none;
animation: none;
}
}
/* ── Narrow (mobile) viewport ──────────────────────────────────────────── */
/* Pairs with NARROW_MAX_WIDTH = 768 in src/client/breakpoints.ts: widths
strictly below 768px (i.e. max-width: 767px) switch the sidebar to the
mobile layout — one full-screen drawer (the bottom panel's tabs were
migrated into the right tree), one toggle button. The structural
switches (which panels render, the migration) are JS-driven via
useNarrowViewport; this block only adapts the pure style details. */
@media (max-width: 767px) {
/* The toggle cluster holds a single button on narrow viewports (the
bottom panel button is not rendered), so the open panel's tab strip
reserves 40px instead of the desktop 72px — the tabs and + menu still
yield space to the one remaining control. */
.panel:not(.panelHidden) .tabBar {
padding-right: 40px;
}
/* Narrower tabs: a phone-width drawer fits more open tabs before the
strip scrolls. */
.tab {
min-width: 48px;
max-width: 128px;
}
}