// 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