// composer — phone-only composer reflow (S3, 2026-08-17).
//
// Appended after home/header so its < 768px rules win the ties against the
// shared <= 1023px block in layout/compat/misc (which keeps the v1.0.0
// composer treatment alive for the 768-1023px tablet range).
//
// Official structure this file reshapes (verified live at 390px, see the S3
// report): InputBar renders
// _card > _row > [_tools > (_add, input[type=file] hidden, _modes >
// PermissionSelect, input.left)]
// [_trailing > (_standardControls > (input.right, input.model), _activity, _primary)]
// with `_tools` / `_trailing` / `_standardControls` as inner flex groups
// (0.1.7 added `_standardControls` and moved the ContextMeter out of the row
// into the dock under the card). That hidden file input is the stand-down
// probe host-attach.ts USED to key on — since #31 the phone's own attach
// button is a permanent resident instead, and DSH 0.1.5's second `_add`
// button (the standalone paperclip 0.2.x removed) is hidden by section 2.
// Flattening all three to
// `display: contents` turns every control into a direct flex item of `_row`,
// which is what makes a pure `order` reflow possible without touching a
// single official component.
/** The composer input card's bottom control row (the only `_row` that is a direct card child — the ContextMeter panel also has `_row` descendants). */
const ROW = '[data-slot="conversation.composer.bar"] [class$="_card"] > [class$="_row"]'
/** The composer's outer column: `_card` on top, 0.1.7's `_dock` (ContextMeter + stats slot) below it. */
const ROOT = '[data-slot="conversation.composer.bar"] > [class$="_root"]'
/** Width the row reserves in front of send for the lifted ContextMeter (14px ring + 8px padding each side; no percentage on the phone). */
const RING_W = '30px'
/** DSH 0.1.7's wrapper around input.right + input.model inside `_trailing`. */
const STD = `${ROW} > [class$="_trailing"] > [class$="_standardControls"]`
/**
* The model seat's content, whichever plugin owns it: officially ModelSelect's
* `_root`, but a third party can replace the whole `conversation.input.model`
* slot (…codex-ui mounts its own selector there), and a subagent session has
* nothing matching `_root` at all. Spelled `*:not(…)` rather than bare `> *`
* on purpose: `:not([data-mobile-nav])` carries the same (0,1,0) the old
* `[class$="_root"]` did, keeping every rule below a full attribute CLASS
* ahead of the ≤1023px pill rules in layout.css.ts — their `[data-phase] …
* :has(…)` forms measure (0,6,1) on the trigger and (0,7,1) on the label
* with `!important`, so a bare `> *` would leave the phone winning by
* exactly one class on every MODEL-derived rule, one upstream attr away
* from losing. The :not() also keeps this plugin's own nodes from ever
* reading as the model seat, should one ever be mounted there.
*/
const MODEL = `${STD} > [data-slot="conversation.input.model"] > *:not([data-mobile-nav])`
/** The permission select's trigger button (structure only — the PermissionSelect hashed classes are never named). */
const PERM = `${ROW} > [class$="_tools"] > [class$="_modes"]`
/**
* ModelSelect's popup, wherever the host renders it: inline under the pill
* (≤ 0.1.2) or portaled to document.body (0.1.5+: `createPortal(..., body)`,
* id `${useId()}-menu` — the only body-level menu that carries such an id;
* the Menu primitive's menus have none). `:is()` takes the specificity of
* its heaviest argument, so the body form inherits the long inline path's
* weight instead of needing its own.
*
* T72: the portaled body form accepts BOTH roles. 0.1.5–rc.1 rendered the
* whole popup as one `role=menu`; rc.2 re-roles the portaled div by pane —
* `role=menu` on the root/effort pane, `role=group` on the model pane (the
* one with the search row and the `menuitemradio` options) — and only the
* `role=menu` spelling stopped matching there, dropping the bottom sheet on
* the second layer. The `[class$="_menu"]` narrowing (rc.1 `xevZdG_menu`,
* rc.2 `cl2Rlq_menu` — both end in `_menu`) keeps a hypothetical unrelated
* body-level `-menu` id from ever matching. The one place this cannot be
* used is the body:has() gate below — a relative selector inside :has()
* cannot start at body — which spells the body form out.
*/
const MODEL_MENU = `:is(${MODEL} > [class$="_menu"], body > [id$="-menu"][class$="_menu"]:is([role="menu"], [role="group"]))`
export const COMPOSER_CSS = `/* ---------- phone composer (< 768px) ---------- */
/* The file picker the attachment button drives (S7) — hidden at EVERY width,
deliberately OUTSIDE the phone media block below. A bare
renders as a native "Choose Files" control, and the slot wrapper around it is
\`display: contents\`, so an un-hidden one becomes a flex item of the official
tool row. Scoping this to < 768px once put that control in the DESKTOP
composer (caught on a live deploy, 2026-08-17): the button that drives it is
phone-only, but the input it drives is in the DOM at all widths. */
[data-mobile-nav="attach-picker"] {
display: none !important;
}
/* The composer-chips strip (effects/model-sheet-extras.ts) — same
every-width default as the picker above: the phone block re-shows it
inside the dock rail; the desktop shell keeps none. */
[data-mobile-nav="composer-chips"] {
display: none !important;
}
@media (max-width: 767px) {
/* --- 1. flatten the two official groups ---
\`_tools\` and \`_trailing\` only exist to cluster controls left/right.
display:contents dissolves them into \`_row\`, so the six leaf controls
become siblings in one flex line and \`order\` alone drives the layout:
[attach · + · permission · model] …elastic gap… [context ring · send]. */
${ROW} {
justify-content: flex-start !important;
gap: 5px !important;
padding: 2px 8px 8px !important;
/* One line, always: the model pill is the only shrinkable item (its
label ellipsizes), so wrapping would only ever push the send button
to a second row (real-device report, 2026-08-25). */
flex-wrap: nowrap !important;
}
${ROW} > [class$="_tools"],
${ROW} > [class$="_trailing"],
${STD} {
display: contents !important;
}
/* --- 2. the running order ---
The attachment button (S3 placeholder, S7 wires it up) sits leftmost and
is a PERMANENT resident now (#31): on DSH 0.2.x the standalone paperclip
moved into the "+" command menu while its hidden
stayed in _tools, so the old host-attach stand-down probe matched on
every host and the phone composer was left with no attach affordance at
all. The official "+" command menu keeps its seat right next to it, where
the two "insert something" affordances read as one cluster. */
${ROW} [data-mobile-nav="attach"] {
order: 1 !important;
}
${ROW} > [class$="_tools"] > [class$="_add"] {
order: 2 !important;
}
/* DSH ≤ 0.1.5 still renders that second _add button (the standalone
paperclip) as a direct _tools child; hide it below 768px so the row
keeps exactly one attach control — ours, which uploads into the session
workspace the phone can actually reach. On 0.2.x the only button in
_tools is the first of its type, so this never matches. */
${ROW} > [class$="_tools"] > button[class$="_add"]:nth-of-type(2) {
display: none !important;
}
${ROW} > [class$="_tools"] > [class$="_modes"] {
order: 3 !important;
/* Rigid on purpose. This seat holds the permission chip, whose own
wrapper (the plugin's element inside _modes) is \`flex: 0 0 auto\` and
will NOT follow the seat down — so a shrinkable seat does not make the
chip narrower, it just slices the seat out from under a chip that
stays 44px wide, and the overflow lands on top of the model pill.
Measured 2026-09-04 on DSH 0.1.2 at 390px: seat 90-120 (29.7px) with
the chip still 90-134, model starting at 125 — a 9px overlap, exactly
the "the two chips touch" report. With the seat rigid: seat 90-134,
model 139-246, overlap 0; and at 320px the model pill shrinks to 52px
and still nothing overlaps.
This also restores what section 1 says out loud — the model pill is
the ONLY shrinkable item in the row. \`min-width: 0\` is gone with the
shrink: it only ever mattered as the companion of \`flex: 0 1\`. */
flex: 0 0 auto !important;
gap: 6px !important;
}
/* The elastic gap used to live here as \`margin-right: auto\`, which broke
the moment a session had no model pill: a subagent session has none, so
the gap vanished and the stop / send buttons bunched up in the middle of
the row instead of sitting at the right edge (reported 2026-09-06). The
gap belongs to the RIGHT group, not to the last item of the left one —
see the two rules below the order list. */
${MODEL} {
order: 4 !important;
/* A definite flex-basis (not auto) breaks the circular sizing between
this seat and the trigger's max-width:100%: the seat's width comes
from flex resolution, the trigger fills up to it, the label
ellipsizes inside. Under nowrap pressure this is the only
min-width:0 seat, so it absorbs all shrink. */
flex: 0 1 min(48vw, 200px) !important;
min-width: 0 !important;
max-width: min(48vw, 200px) !important;
}
/* Third-party input.right entries park next to the ring rather than
landing at order 0 (= far left) once the groups are flattened. */
${STD} > [data-slot="conversation.input.right"] > * {
order: 5 !important;
}
/* ContextMeter. 0.1.7 moved it out of the row into ROOT's \`_dock\`, a
line UNDER the card — on the phone that dropped the ring to the very
bottom of the screen (user report, 2026-09-28). Lifted back beside send
without moving any official node: ROOT becomes a one-cell grid and the
dock shares the card's cell, pinned to the card's bottom-right corner,
over a placeholder the row reserves in front of send (row::before at
order 6, below). Anchoring to the card — not to ROOT's bottom edge —
matters: ROOT's bottom padding follows the safe-area inset, so an
absolute \`bottom\` offset measured in the emulator sat low on a real
iPhone (user screenshot, 2026-09-29). Vertical: the row's 8px bottom
pad, 34px tall = the send button's box. Horizontal: row pad 8 + send 34
+ gap 5. The percentage is dropped on the phone (user call): ring only. */
${ROOT}:has(> [class$="_dock"] button) {
display: grid !important;
grid-template-columns: minmax(0, 1fr) !important;
justify-items: center !important;
}
${ROOT}:has(> [class$="_dock"] button) > [class$="_card"] {
grid-area: 1 / 1 !important;
}
${ROOT} > [class$="_dock"] {
grid-area: 1 / 1 !important;
align-self: end !important;
justify-self: end !important;
position: relative !important;
z-index: 1;
height: 34px !important;
margin: 0 47px 8px 0 !important;
padding: 0 !important;
gap: 0 !important;
}
${ROOT} > [class$="_dock"] button[class$="_trigger"] {
padding: 8px !important;
gap: 0 !important;
}
${ROOT} > [class$="_dock"] button[class$="_trigger"] > span {
display: none !important;
}
${ROOT}:has(> [class$="_dock"] button) > [class$="_card"] > [class$="_row"]::before {
content: '';
order: 6;
flex: 0 0 ${RING_W};
margin-left: auto;
}
${ROW} > [class$="_trailing"] > [class$="_primary"] {
order: 7 !important;
}
/* The elastic gap. It sits in front of whichever of the two right-hand
items comes first, so the group hugs the right edge no matter what the
left side happens to contain — with a model pill, without one (subagent
sessions), with or without the third-party entries at order 5.
Send carries \`margin-left: auto\` on its own; when the lifted ring's
placeholder (row::before, above) precedes it, the placeholder carries
the auto margin instead and the rule below takes it back off send —
two auto margins would SHARE the free space and open a second gap
between the ring and send. */
${ROW} > [class$="_trailing"] > [class$="_primary"] {
margin-left: auto !important;
}
${ROOT}:has(> [class$="_dock"] button) > [class$="_card"] > [class$="_row"] > [class$="_trailing"] > [class$="_primary"] {
margin-left: 0 !important;
}
/* --- 3. permission as icon-only pill; model as name+effort pill ---
(real-device round 2, 2026-08-17; model pill reworked 2026-08-25:
the user wants to SEE which model/effort is active, so only the
permission trigger stays icon-only — its label is hidden and it
collapses to a plain ~44x30 icon button. This AGREES with (rather than fights) the official
container query (\`@container (width <= 460px) { .trigger:has(.triggerIcon)
.triggerLabel { display: none } }\`) that S3 had to override — no need
to override it back. Accessible name is unaffected: both official
triggers already ship a descriptive aria-label independent of the
visible text (PermissionSelect: t('input.accessMode', {name}); the
model trigger: t('trigger.aria'/'trigger.ariaEffort')) — verified live
in dsh-client-ui-conversation / dsh-client-ui-model-selection lib/
client.js, 2026-08-17 — so hiding the label costs nothing for screen
readers. The permission trigger only renders an icon for the
"read-only" / "workspace-write" presets (permissionGlyphs in
dsh-client-ui-conversation) — "Full access" and any host-configured
preset name fall back to chevron-only, a known gap in the official
markup this plugin cannot fill without inventing new icon meaning.
The model trigger never renders an icon at all (only label + optional
effort text + chevron), so its ::before below draws one from a
primitives icon path (Sparkle — the closest existing "model" glyph) as
a CSS-only pseudo-element: it survives React re-renders for free
(unlike a DOM-injected node, which would need a MutationObserver, see
the preview-full-toggle pitfall in AGENTS.md) and does not touch
accessible-name computation (empty generated content). S3's
rtl-ellipsis trick on the model label is simply inert under
display:none now; left alone rather than unpicked. */
${PERM} [class$="_triggerLabel"] {
display: none !important;
}
${PERM} button[class$="_trigger"] {
background: var(--dsw-specific-selector, rgba(127, 127, 127, .12)) !important;
width: 44px !important;
height: 30px !important;
max-width: none !important;
min-width: 0 !important;
padding: 0 !important;
gap: 2px !important;
justify-content: center !important;
border-radius: 999px !important;
touch-action: manipulation !important;
}
/* Model trigger: name + effort pill (2026-08-25 user request — the
icon-only form hid which model was active). The effort text must stay
whole; the model NAME is the elastic part and ellipsizes. max-width
caps the pill so the row's elastic gap survives extreme model ids;
the label rules below override the <=1023px grow rules in
layout.css.ts (flex 1 1 auto) that would otherwise pad the pill. */
${MODEL} > [class$="_trigger"],
${MODEL} > button {
background: var(--dsw-specific-selector, rgba(127, 127, 127, .12)) !important;
height: 30px !important;
width: auto !important;
max-width: 100% !important;
min-width: 0 !important;
padding: 0 10px !important;
gap: 4px !important;
justify-content: flex-start !important;
border-radius: 999px !important;
touch-action: manipulation !important;
font-size: 12px !important;
}
${MODEL} > [class$="_trigger"] > [class$="_triggerLabel"] {
display: block !important;
flex: 0 1 auto !important;
min-width: 0 !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
${MODEL} > [class$="_trigger"] > [class$="_triggerEffort"] {
display: block !important;
flex: none !important;
white-space: nowrap !important;
}
/* Third-party model selectors (…codex-ui) render a bare