chore: archive design handoff bundle for toolbar refresh
Stores SethMux_4-24-26.zip + extracted design_handoff_sethmux_toolbar/ so the spec, mockups, and reference jsx components stay in-repo for future reference. Not served in production — the only file that ships is static/toolbar.js, which already matches the design's toolbar.js byte-for-byte.
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,145 @@
|
|||||||
|
# Handoff: sethmux mobile toolbar — Google Workspace dark refresh + compose bar
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This handoff covers two related changes to the sethmux web terminal's mobile UI (`static/toolbar.js`):
|
||||||
|
|
||||||
|
1. **Theme refresh** — re-skin the existing 2-row mobile button bar to match the Google Workspace dark vocabulary (palette, typography, hairline borders, hover behavior) while keeping sethmux's orange `#D35400` as the accent identity.
|
||||||
|
2. **Mobile autocorrect workaround** — add an opt-in compose bar (third row, toggled by a "Type" button) that gives Gboard / iOS keyboards a real `<input>` to operate on. Typed text is flushed to the terminal stdin on Enter / Send so swipe, autocorrect, and predictions all work despite xterm.js's per-keystroke input model.
|
||||||
|
|
||||||
|
## About the Design Files
|
||||||
|
|
||||||
|
The files in this bundle are **design references** — `toolbar.js` is the single source file to ship; the `preview*.html` files are visual mockups demonstrating intended look and behavior in a Chrome window and an Android phone frame, and the `*.jsx` files are throwaway frame components used only by the previews.
|
||||||
|
|
||||||
|
The task is to drop the new `toolbar.js` into the existing sethmux project at `static/toolbar.js`, replacing the current file. No build step required; no other static files change. The previews are not meant to be served in production.
|
||||||
|
|
||||||
|
## Fidelity
|
||||||
|
|
||||||
|
**High-fidelity.** All colors, type sizes, spacings, radii, and behaviors are final. Implement to spec.
|
||||||
|
|
||||||
|
## What changed in toolbar.js
|
||||||
|
|
||||||
|
### Visual system (Google Workspace dark)
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|------------------|-----------|-----------------------------------------------|
|
||||||
|
| toolbar bg | `#202124` | bar background |
|
||||||
|
| button surface | `#303134` | button face at rest |
|
||||||
|
| border | `#3c4043` | 1px hairlines, group dividers |
|
||||||
|
| primary text | `#e8eaed` | button labels |
|
||||||
|
| secondary text | `#9aa0a6` | mono-class buttons at rest |
|
||||||
|
| tertiary text | `#5f6368` | placeholder, disabled |
|
||||||
|
| accent (sethmux) | `#D35400` | active/toggled fill, send button, focus ring |
|
||||||
|
| accent at rest | `#f0a36b` | text color of `.hi` (orange-tinted) buttons |
|
||||||
|
| accent bg-1 | `#2a1f15` | `.hi` button background at rest |
|
||||||
|
| accent bg-2 | `#3a2a1a` | hover wash (subtle orange tint) |
|
||||||
|
| accent border | `#5a3a22` | `.hi` border at rest |
|
||||||
|
| success at rest | `#81c995` | Save button text |
|
||||||
|
| success filled | `#1e8e3e` | Save button after confirm |
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- Primary: `Roboto, 'Helvetica Neue', Arial, sans-serif`, 12px / 500, letter-spacing 0.1px
|
||||||
|
- Mono (chord and arrow keys, terminal input): `'Roboto Mono', 'SF Mono', ui-monospace, Menlo, Consolas, monospace`, 14px / 400 in the compose input, 12px / 400 on `.mono` buttons
|
||||||
|
|
||||||
|
### Geometry
|
||||||
|
|
||||||
|
- Bar padding: `6px 8px 7px`
|
||||||
|
- Button: `height: 32px`, `min-width: 40px`, `padding: 0 10px`, `border-radius: 4px`, `border: 1px solid #3c4043`
|
||||||
|
- Narrow-phone breakpoint at `max-width: 380px`: button `height: 30px`, `min-width: 34px`, `padding: 0 7px`, `font-size: 11.5px`
|
||||||
|
- Compose input: `height: 36px`, `padding: 0 10px`, `border-radius: 4px`, orange caret + orange focus border `#D35400`
|
||||||
|
- Send button: `height: 36px`, `min-width: 54px`, filled `#D35400` on text `#0a0a0a`
|
||||||
|
- Newline button (`↵`): same height as Send, `min-width: 38px`, neutral surface (`#303134` / `#3c4043`)
|
||||||
|
- Group dividers (`.sep`): 1px wide × 20px tall, `#3c4043`, 4px horizontal margin (2px on narrow phones)
|
||||||
|
- Bar shadow: `0 -1px 0 rgba(0,0,0,.4), 0 -8px 24px rgba(0,0,0,.35)`
|
||||||
|
- Transitions: `background .15s ease, border-color .15s ease, color .15s ease` (no bounces)
|
||||||
|
|
||||||
|
### Button states
|
||||||
|
|
||||||
|
- Default: `#303134` bg, `#3c4043` border, `#e8eaed` text
|
||||||
|
- Hover: `#3a2a1a` bg, `#fff` text
|
||||||
|
- Active (mouse-down): full orange fill `#D35400` on `#0a0a0a`
|
||||||
|
- `.hi` (accent at rest): orange-tinted bg `#2a1f15`, border `#5a3a22`, text `#f0a36b`. Hover deepens both
|
||||||
|
- `.on` (toggled, e.g. selection mode active, Type active): full `#D35400` fill, hover lightens to `#e26416`
|
||||||
|
- `.mono` (chord keys): mono font, `#9aa0a6` text → `#e8eaed` on hover
|
||||||
|
- `.grn` (Save): `#81c995` text at rest; `.grn.on` (post-confirm) fills `#1e8e3e` and shows "✓ Saved" for 1500ms
|
||||||
|
|
||||||
|
### Layout / structure
|
||||||
|
|
||||||
|
The bar is a single `position: fixed; bottom: 0` element with `flex-direction: column`.
|
||||||
|
|
||||||
|
**Row 1** (always visible):
|
||||||
|
```
|
||||||
|
[+ Tab] [Next] [Prev] | [^C] [^D] [Clr] | [Esc] [Tab] [▲] [▼]
|
||||||
|
```
|
||||||
|
- `+ Tab` is `.hi`, the rest neutral, `^C ^D Esc Tab ▲ ▼` are `.mono`.
|
||||||
|
|
||||||
|
**Row 2** (always visible):
|
||||||
|
```
|
||||||
|
[Sel] [Paste] [Zoom] [Save] | [V.Spl] [H.Spl] [Pane] [Kill] | [Type]
|
||||||
|
```
|
||||||
|
- `Sel`, `Paste`, `Type` are `.hi`. `Save` is `.grn`. The `Type` button is the new entry point for the compose bar.
|
||||||
|
|
||||||
|
**Row 3 — compose** (only visible when `#mb` has class `composing`):
|
||||||
|
```
|
||||||
|
[ input field ] [↵] [Send]
|
||||||
|
```
|
||||||
|
- The input is full-width-flex, monospace, autocorrect on. `↵` and `Send` both flush.
|
||||||
|
|
||||||
|
### Interactions / behavior
|
||||||
|
|
||||||
|
**Send to stdin (`send(k)`):** unchanged. Tries `term._core.coreService.triggerDataEvent(k)` first, falls back to `term._core._onData.fire(k)`, then `term.input(k)`. Always re-focuses the terminal afterward.
|
||||||
|
|
||||||
|
**Selection mode (`Sel`):** unchanged. Toggles `body.selmode` which adds `pointer-events: none` to `.xterm-screen` and forces text selection. While in selmode the body also gets `filter: brightness(.92)` for a subtle visual cue. Sending any key auto-exits selmode.
|
||||||
|
|
||||||
|
**Paste:** unchanged. Reads `navigator.clipboard.readText()` and feeds the result through `send()`. Alerts if HTTPS clipboard is unavailable.
|
||||||
|
|
||||||
|
**Save:** unchanged behavior — sends `\x01S` (`Ctrl-A S`, the tmux capture binding). Adds the `.on` class and label `✓ Saved` for 1500ms, then reverts.
|
||||||
|
|
||||||
|
**Compose / Type (new):**
|
||||||
|
- Tapping `Type` toggles the `composing` class on `#mb`.
|
||||||
|
- On open, `#mb-compose` gets focused via `setTimeout(...,0)` so iOS/Android keyboards open reliably.
|
||||||
|
- Input attributes: `autocomplete="on" autocorrect="on" autocapitalize="sentences" spellcheck="true" enterkeyhint="send" inputmode="text"`. These are required — the whole point is to let the OS keyboard's correction layer mutate the input value, which xterm.js's hidden textarea cannot do.
|
||||||
|
- Pressing `Enter` (or tapping `Send` or `↵`) calls `flushCompose(true)`: reads the input value, clears it, sends the value as a string to stdin, then sends `\r`. Re-focuses the input so the user can keep typing.
|
||||||
|
- Other toolbar buttons remain active while composing. If a chord/arrow/etc. button is tapped with non-empty input text, the typed text is flushed first (preserving order), then the chord is sent. **The compose bar stays open**; the user is mid-thought.
|
||||||
|
|
||||||
|
**Layout reflow (`relayout()`):** The terminal pane (`.xterm`) is sized via `height: calc(100vh - {barHeight + 4}px)` whenever the bar's height changes. This runs on:
|
||||||
|
- DOM mutations (existing `MutationObserver`)
|
||||||
|
- Window resize
|
||||||
|
- Toggling the `composing` class (called from `toggleType()`)
|
||||||
|
|
||||||
|
The previous implementation hard-coded `calc(100vh - 92px)`; the new code measures `bar.offsetHeight` so opening the third row reflows correctly without overlap.
|
||||||
|
|
||||||
|
**Helper textarea hardening:** unchanged. Still sets `autocomplete=off`, `autocorrect=off`, `autocapitalize=off`, `spellcheck=false` on `.xterm-helper-textarea` once it appears. This is the standard xterm.js mitigation and is still useful — it prevents Gboard from trying to munge the per-keystroke stream when the user happens to type *outside* the compose bar.
|
||||||
|
|
||||||
|
### Mobile breakpoint
|
||||||
|
|
||||||
|
The toolbar shows at `@media (max-width: 900px)`. Unchanged. Above that, sethmux assumes a real keyboard and the bar stays hidden.
|
||||||
|
|
||||||
|
## Why the compose bar is necessary
|
||||||
|
|
||||||
|
xterm.js reads from a hidden `<textarea>` and forwards `keydown` / `keypress` events plus IME `compositionstart` / `compositionend` to the terminal's data event one keystroke at a time. Mobile keyboards' autocorrect, swipe input, and word predictions don't fire per-key events — they replace the textarea's `.value` in bulk via `input` events with `inputType: "insertReplacementText"` or `"insertCompositionText"`. xterm.js's input handler discards those, so corrections silently fail. The compose bar sidesteps this by giving the OS keyboard a real text field, then sending the assembled string in one shot.
|
||||||
|
|
||||||
|
## Files in this bundle
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|----------------------------|-------------------------------------------------------------------|
|
||||||
|
| `toolbar.js` | **Ship this.** Drop into `static/toolbar.js`. |
|
||||||
|
| `preview.html` | Minimal mock terminal + the live `toolbar.js` for in-browser test |
|
||||||
|
| `preview-desktop.html` | Chrome window mock showing the full sethmux UI with tmux tabs |
|
||||||
|
| `preview-mobile.html` | Two Android frames: collapsed state + compose-open state |
|
||||||
|
| `browser-window.jsx` | Used only by `preview-desktop.html` |
|
||||||
|
| `android-frame.jsx` | Used only by `preview-mobile.html` |
|
||||||
|
|
||||||
|
## Acceptance checklist
|
||||||
|
|
||||||
|
- [ ] Bar appears only at viewport widths ≤ 900px.
|
||||||
|
- [ ] All 20 original buttons present and bound to the same key sequences.
|
||||||
|
- [ ] `Type` button toggles a third row with input + `↵` + `Send`.
|
||||||
|
- [ ] Pressing Enter inside the input sends the typed text plus `\r` and clears the field.
|
||||||
|
- [ ] Tapping any other toolbar button while composing flushes the typed text first, then sends the button's key. Compose bar stays open.
|
||||||
|
- [ ] Terminal pane `height` reflows when the bar grows/shrinks; nothing overlaps.
|
||||||
|
- [ ] Autocorrect / swipe / predictions work in the compose input on Gboard and iOS.
|
||||||
|
- [ ] Save button shows `✓ Saved` filled green for 1500ms after tap.
|
||||||
|
- [ ] Sel button toggles to `Done` (filled orange) and dims the terminal slightly.
|
||||||
|
- [ ] No console errors; `window._toolbar` guards against double-init.
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
|
||||||
|
// Android.jsx — Simplified Android (Material 3) device frame
|
||||||
|
// Status bar + top app bar + content + gesture nav + keyboard.
|
||||||
|
// Based on Figma M3 spec. No dependencies, no image assets.
|
||||||
|
|
||||||
|
const MD_C = {
|
||||||
|
surface: '#f4fbf8',
|
||||||
|
surfaceVariant: '#dae5e1',
|
||||||
|
inverseOnSurface: '#ecf2ef',
|
||||||
|
secondaryContainer: '#cde8e1',
|
||||||
|
primaryFixedDim: '#83d5c6',
|
||||||
|
onSurface: '#171d1b',
|
||||||
|
onSurfaceVar: '#49454f',
|
||||||
|
onPrimaryContainer: '#00201c',
|
||||||
|
primary: '#006a60',
|
||||||
|
frameBorder: 'rgba(116,119,117,0.5)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Status bar (time left, wifi/cell/battery right)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function AndroidStatusBar({ dark = false }) {
|
||||||
|
const c = dark ? '#fff' : MD_C.onSurface;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: 40, display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'space-between', padding: '0 16px',
|
||||||
|
position: 'relative',
|
||||||
|
fontFamily: 'Roboto, system-ui, sans-serif',
|
||||||
|
}}>
|
||||||
|
{/* time left */}
|
||||||
|
<div style={{ width: 128, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 400, letterSpacing: 0.25, lineHeight: '20px', color: c }}>9:30</span>
|
||||||
|
</div>
|
||||||
|
{/* camera punch-hole (center) */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: '50%', top: 8, transform: 'translateX(-50%)',
|
||||||
|
width: 24, height: 24, borderRadius: 100, background: '#2e2e2e',
|
||||||
|
}} />
|
||||||
|
{/* status icons right */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', paddingRight: 2 }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
|
||||||
|
<path d="M8 13.3L.67 5.97a10.37 10.37 0 0114.66 0L8 13.3z" fill={c}/>
|
||||||
|
</svg>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
|
||||||
|
<path d="M14.67 14.67V1.33L1.33 14.67h13.34z" fill={c}/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<rect x="3.75" y="2" width="8.5" height="13" rx="1.5" fill={c}/>
|
||||||
|
<rect x="5.5" y="0.9" width="5" height="2" rx="0.5" fill={c}/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Top app bar (Material 3 small/medium)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function AndroidAppBar({ title = 'Title', large = false }) {
|
||||||
|
const iconDot = (
|
||||||
|
<div style={{
|
||||||
|
width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: 22, height: 22, borderRadius: '50%', background: MD_C.onSurfaceVar, opacity: 0.3 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{ background: MD_C.surface, padding: '4px 4px 0' }}>
|
||||||
|
<div style={{ height: 56, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{iconDot}
|
||||||
|
{!large && (
|
||||||
|
<span style={{
|
||||||
|
flex: 1, fontSize: 22, fontWeight: 400, color: MD_C.onSurface,
|
||||||
|
fontFamily: 'Roboto, system-ui, sans-serif',
|
||||||
|
}}>{title}</span>
|
||||||
|
)}
|
||||||
|
{large && <div style={{ flex: 1 }} />}
|
||||||
|
{iconDot}
|
||||||
|
</div>
|
||||||
|
{large && (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 16px 20px',
|
||||||
|
fontSize: 28, fontWeight: 400, color: MD_C.onSurface,
|
||||||
|
fontFamily: 'Roboto, system-ui, sans-serif',
|
||||||
|
}}>{title}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// List item (Material 3)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function AndroidListItem({ headline, supporting, leading }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 16,
|
||||||
|
padding: '12px 16px', minHeight: 56, boxSizing: 'border-box',
|
||||||
|
fontFamily: 'Roboto, system-ui, sans-serif',
|
||||||
|
}}>
|
||||||
|
{leading && (
|
||||||
|
<div style={{
|
||||||
|
width: 40, height: 40, borderRadius: '50%',
|
||||||
|
background: MD_C.primary, color: '#fff',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 18, fontWeight: 500, flexShrink: 0,
|
||||||
|
}}>{leading}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 16, color: MD_C.onSurface, lineHeight: '24px' }}>{headline}</div>
|
||||||
|
{supporting && (
|
||||||
|
<div style={{ fontSize: 14, color: MD_C.onSurfaceVar, lineHeight: '20px' }}>{supporting}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Gesture nav bar (pill)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function AndroidNavBar({ dark = false }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 108, height: 4, borderRadius: 2,
|
||||||
|
background: dark ? '#fff' : MD_C.onSurface, opacity: 0.4,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Device frame — wraps everything
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function AndroidDevice({
|
||||||
|
children, width = 412, height = 892, dark = false,
|
||||||
|
title, large = false, keyboard = false,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width, height, borderRadius: 18, overflow: 'hidden',
|
||||||
|
background: dark ? '#1d1b20' : MD_C.surface,
|
||||||
|
border: `8px solid ${MD_C.frameBorder}`,
|
||||||
|
boxShadow: '0 30px 80px rgba(0,0,0,0.25)',
|
||||||
|
display: 'flex', flexDirection: 'column', boxSizing: 'border-box',
|
||||||
|
}}>
|
||||||
|
<AndroidStatusBar dark={dark} />
|
||||||
|
{title !== undefined && <AndroidAppBar title={title} large={large} />}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{keyboard && <AndroidKeyboard />}
|
||||||
|
<AndroidNavBar dark={dark} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Keyboard — Gboard (Material 3)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function AndroidKeyboard() {
|
||||||
|
let _k = 0;
|
||||||
|
const key = (l, { flex = 1, bg = MD_C.surface, r = 6, minW, fs = 21 } = {}) => (
|
||||||
|
<div key={_k++} style={{
|
||||||
|
height: 46, borderRadius: r, flex, minWidth: minW,
|
||||||
|
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontFamily: 'Roboto, system-ui', fontSize: fs,
|
||||||
|
color: MD_C.onPrimaryContainer,
|
||||||
|
}}>{l}</div>
|
||||||
|
);
|
||||||
|
const row = (keys, style = {}) => (
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', ...style }}>
|
||||||
|
{keys.map(l => key(l))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: MD_C.inverseOnSurface, padding: '0 8px 8px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 4,
|
||||||
|
}}>
|
||||||
|
{/* navbar spacer (icons omitted) */}
|
||||||
|
<div style={{ height: 44 }} />
|
||||||
|
{/* key rows */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{row(['q','w','e','r','t','y','u','i','o','p'])}
|
||||||
|
{row(['a','s','d','f','g','h','j','k','l'], { padding: '0 20px' })}
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{key('', { bg: MD_C.surfaceVariant })}
|
||||||
|
<div style={{ display: 'flex', gap: 6, flex: 7, minWidth: 274 }}>
|
||||||
|
{['z','x','c','v','b','n','m'].map(l => key(l))}
|
||||||
|
</div>
|
||||||
|
{key('', { bg: MD_C.surfaceVariant })}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{key('?123', { bg: MD_C.secondaryContainer, r: 100, minW: 58, fs: 14 })}
|
||||||
|
{key(',', { bg: MD_C.surfaceVariant })}
|
||||||
|
{key('', { flex: 3, minW: 154 })}
|
||||||
|
{key('.', { bg: MD_C.surfaceVariant })}
|
||||||
|
{key('', { bg: MD_C.primaryFixedDim, r: 100, minW: 58 })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
AndroidDevice, AndroidStatusBar, AndroidAppBar, AndroidListItem, AndroidNavBar, AndroidKeyboard,
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
|
||||||
|
// Chrome.jsx — Simplified Chrome browser window (dark theme, macOS)
|
||||||
|
// No dependencies, no image assets. All inline styles + inline SVG.
|
||||||
|
|
||||||
|
const CHROME_C = {
|
||||||
|
barBg: '#202124',
|
||||||
|
tabBg: '#35363a',
|
||||||
|
text: '#e8eaed',
|
||||||
|
dim: '#9aa0a6',
|
||||||
|
urlBg: '#282a2d',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChromeTrafficLights() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 8, padding: '0 14px' }}>
|
||||||
|
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f57' }} />
|
||||||
|
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#febc2e' }} />
|
||||||
|
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#28c840' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single tab (active has curved scoops)
|
||||||
|
function ChromeTab({ title = 'New Tab', active = false }) {
|
||||||
|
const curve = (flip) => (
|
||||||
|
<svg width="8" height="10" viewBox="0 0 8 10"
|
||||||
|
style={{ position: 'absolute', bottom: 0, [flip ? 'right' : 'left']: -8, transform: flip ? 'scaleX(-1)' : 'none' }}>
|
||||||
|
<path d="M0 10C2 9 6 8 8 0V10H0Z" fill={CHROME_C.tabBg}/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', height: 34, alignSelf: 'flex-end',
|
||||||
|
padding: '0 12px', display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
background: active ? CHROME_C.tabBg : 'transparent',
|
||||||
|
borderRadius: '8px 8px 0 0', minWidth: 120, maxWidth: 220,
|
||||||
|
fontFamily: 'system-ui, sans-serif', fontSize: 12,
|
||||||
|
color: active ? CHROME_C.text : CHROME_C.dim,
|
||||||
|
}}>
|
||||||
|
{active && curve(false)}
|
||||||
|
{active && curve(true)}
|
||||||
|
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#5f6368', flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{title}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChromeTabBar({ tabs = [{ title: 'New Tab' }], activeIndex = 0 }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', height: 44,
|
||||||
|
background: CHROME_C.barBg, paddingRight: 8,
|
||||||
|
}}>
|
||||||
|
<ChromeTrafficLights />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', height: '100%', paddingLeft: 4, flex: 1 }}>
|
||||||
|
{tabs.map((t, i) => <ChromeTab key={i} title={t.title} active={i === activeIndex} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChromeToolbar({ url = 'example.com' }) {
|
||||||
|
const iconDot = (
|
||||||
|
<div style={{
|
||||||
|
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: 16, height: 16, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: 40, background: CHROME_C.tabBg,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4, padding: '0 8px',
|
||||||
|
}}>
|
||||||
|
{iconDot}
|
||||||
|
{/* url bar */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1, height: 30, borderRadius: 15, background: CHROME_C.urlBg,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px',
|
||||||
|
margin: '0 6px',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: 12, height: 12, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
|
||||||
|
<span style={{
|
||||||
|
flex: 1, color: CHROME_C.text, fontSize: 13,
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
}}>{url}</span>
|
||||||
|
</div>
|
||||||
|
{iconDot}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChromeWindow({
|
||||||
|
tabs = [{ title: 'New Tab' }], activeIndex = 0, url = 'example.com',
|
||||||
|
width = 900, height = 600, children,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width, height, borderRadius: 10, overflow: 'hidden',
|
||||||
|
boxShadow: '0 24px 80px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.1)',
|
||||||
|
display: 'flex', flexDirection: 'column', background: CHROME_C.tabBg,
|
||||||
|
}}>
|
||||||
|
<ChromeTabBar tabs={tabs} activeIndex={activeIndex} />
|
||||||
|
<ChromeToolbar url={url} />
|
||||||
|
<div style={{ flex: 1, background: '#fff', overflow: 'auto' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
ChromeWindow, ChromeTabBar, ChromeToolbar, ChromeTab, ChromeTrafficLights,
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>sethmux — desktop with tabs</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&family=Roboto+Mono:wght@400&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html,body{height:100%}
|
||||||
|
body{
|
||||||
|
background:#0d0d10;
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
font:12px/1.4 'Roboto','Helvetica Neue',Arial,sans-serif;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
padding:24px;
|
||||||
|
overflow:auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── App inside the browser ───────────────────────── */
|
||||||
|
.app{
|
||||||
|
background:#1a1a1d;
|
||||||
|
color:#e8eaed;
|
||||||
|
height:100%;
|
||||||
|
display:flex;flex-direction:column;
|
||||||
|
font:12px/1.4 'Roboto','Helvetica Neue',Arial,sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sethmux own top chrome (matches Google-workspace dark) */
|
||||||
|
.top{
|
||||||
|
height:48px;flex-shrink:0;
|
||||||
|
background:#202124;border-bottom:1px solid #3c4043;
|
||||||
|
display:flex;align-items:center;padding:0 14px;gap:14px;
|
||||||
|
}
|
||||||
|
.brand{
|
||||||
|
color:#D35400;font-weight:500;font-size:14px;letter-spacing:.2px;
|
||||||
|
font-family:'Roboto',sans-serif;
|
||||||
|
}
|
||||||
|
.tabs{display:flex;gap:0;height:100%;align-items:stretch}
|
||||||
|
.tab{
|
||||||
|
display:flex;align-items:center;gap:8px;
|
||||||
|
padding:0 14px;height:100%;
|
||||||
|
color:#9aa0a6;font-size:12px;font-weight:500;
|
||||||
|
border-bottom:2px solid transparent;cursor:default;
|
||||||
|
}
|
||||||
|
.tab.active{color:#D35400;border-bottom-color:#D35400}
|
||||||
|
.tab .num{
|
||||||
|
font-family:'Roboto Mono',ui-monospace,monospace;font-size:10px;
|
||||||
|
color:#5f6368;
|
||||||
|
}
|
||||||
|
.tab.active .num{color:#D35400;opacity:.8}
|
||||||
|
.tab .dot{
|
||||||
|
width:6px;height:6px;border-radius:50%;background:#3c4043;
|
||||||
|
}
|
||||||
|
.tab.active .dot{background:#D35400}
|
||||||
|
.tab.alert .dot{background:#fdd663}
|
||||||
|
.spacer{flex:1}
|
||||||
|
.meta{
|
||||||
|
color:#9aa0a6;font-family:'Roboto Mono',ui-monospace,monospace;font-size:11px;
|
||||||
|
}
|
||||||
|
.meta b{color:#e8eaed;font-weight:400}
|
||||||
|
|
||||||
|
/* terminal */
|
||||||
|
.xterm{
|
||||||
|
flex:1;
|
||||||
|
padding:12px 14px;
|
||||||
|
overflow:hidden;
|
||||||
|
font-family:'Roboto Mono',ui-monospace,Menlo,Consolas,monospace;
|
||||||
|
font-size:13px;line-height:1.5;
|
||||||
|
color:#cfd2d6;background:#1a1a1d;
|
||||||
|
}
|
||||||
|
.ps1{color:#81c995}
|
||||||
|
.path{color:#8ab4f8}
|
||||||
|
.git{color:#fdd663}
|
||||||
|
.cmd{color:#e8eaed}
|
||||||
|
.dim{color:#5f6368}
|
||||||
|
.err{color:#f28b82}
|
||||||
|
.ok{color:#81c995}
|
||||||
|
.cursor{
|
||||||
|
display:inline-block;width:8px;height:15px;background:#e8eaed;
|
||||||
|
vertical-align:-2px;margin-left:1px;animation:blink 1.1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink{50%{opacity:0}}
|
||||||
|
|
||||||
|
/* status bar (above toolbar on desktop) */
|
||||||
|
.status{
|
||||||
|
height:24px;flex-shrink:0;
|
||||||
|
background:#202124;border-top:1px solid #3c4043;
|
||||||
|
display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:0 14px;color:#9aa0a6;
|
||||||
|
font-family:'Roboto Mono',ui-monospace,monospace;font-size:11px;
|
||||||
|
}
|
||||||
|
.status .left,.status .right{display:flex;gap:14px;align-items:center}
|
||||||
|
.status b{color:#e8eaed;font-weight:400}
|
||||||
|
.status .ok{color:#81c995}
|
||||||
|
|
||||||
|
/* desktop hides the mobile toolbar — show it anyway in this preview
|
||||||
|
so reviewers can see what mobile users get */
|
||||||
|
#mb{display:flex !important;position:relative !important;
|
||||||
|
box-shadow:0 -1px 0 #3c4043 !important;flex-shrink:0;}
|
||||||
|
/* Chrome wrapper sets its own background */
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
<script type="text/babel" src="browser-window.jsx"></script>
|
||||||
|
|
||||||
|
<script type="text/babel">
|
||||||
|
const { useEffect } = React;
|
||||||
|
|
||||||
|
function SethmuxApp() {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<div className="top">
|
||||||
|
<div className="brand">sethmux</div>
|
||||||
|
<div className="tabs">
|
||||||
|
<div className="tab"><span className="num">1</span><span className="dot"/>code</div>
|
||||||
|
<div className="tab active"><span className="num">2</span><span className="dot"/>git</div>
|
||||||
|
<div className="tab alert"><span className="num">3</span><span className="dot"/>run</div>
|
||||||
|
<div className="tab"><span className="num">4</span><span className="dot"/>logs</div>
|
||||||
|
<div className="tab"><span className="num">5</span><span className="dot"/>notes</div>
|
||||||
|
</div>
|
||||||
|
<div className="spacer"/>
|
||||||
|
<div className="meta">tmux <b>sethmux</b> · 1 client · 168×42</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="xterm">
|
||||||
|
<div><span className="ps1">seth@mux</span> <span className="path">~/sethmux</span> <span className="git">(main)</span> <span className="dim">$</span> <span className="cmd">git status</span></div>
|
||||||
|
<div>On branch <span className="ok">main</span></div>
|
||||||
|
<div>Your branch is up to date with <span className="path">'origin/main'</span>.</div>
|
||||||
|
<div> </div>
|
||||||
|
<div>Changes not staged for commit:</div>
|
||||||
|
<div className="dim"> (use "git add <file>..." to update what will be committed)</div>
|
||||||
|
<div> </div>
|
||||||
|
<div> <span className="err">modified:</span> static/toolbar.js</div>
|
||||||
|
<div> <span className="err">modified:</span> README.md</div>
|
||||||
|
<div> </div>
|
||||||
|
<div>Untracked files:</div>
|
||||||
|
<div> <span className="err">static/compose-bar.js</span></div>
|
||||||
|
<div> </div>
|
||||||
|
<div><span className="ps1">seth@mux</span> <span className="path">~/sethmux</span> <span className="git">(main)</span> <span className="dim">$</span> <span className="cmd">git diff --stat static/toolbar.js</span></div>
|
||||||
|
<div> static/toolbar.js | <span className="ok">+138</span> <span className="err">-21</span></div>
|
||||||
|
<div> </div>
|
||||||
|
<div><span className="ps1">seth@mux</span> <span className="path">~/sethmux</span> <span className="git">(main)</span> <span className="dim">$</span> <span className="cursor"/></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="status">
|
||||||
|
<div className="left">
|
||||||
|
<span><b>code</b> · <b>git</b> · run · logs · notes</span>
|
||||||
|
<span>pane <b>0</b>/1</span>
|
||||||
|
</div>
|
||||||
|
<div className="right">
|
||||||
|
<span className="ok">● connected</span>
|
||||||
|
<span><b>14:02</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* mobile toolbar mounts at body level via toolbar.js */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
// Load the real toolbar after render so it can find a place to mount.
|
||||||
|
if (window._toolbar) return;
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'toolbar.js';
|
||||||
|
document.body.appendChild(s);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ title: 'sethmux — git' },
|
||||||
|
{ title: 'GitHub · sethmux' },
|
||||||
|
{ title: 'PR #42 · review' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||||||
|
<ChromeWindow tabs={tabs} activeIndex={0} url="mux.seth.dev" width={1180} height={760}>
|
||||||
|
<SethmuxApp/>
|
||||||
|
</ChromeWindow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>sethmux — mobile</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&family=Roboto+Mono:wght@400&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html,body{height:100%}
|
||||||
|
body{
|
||||||
|
background:#0d0d10;
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
font-family:'Roboto',sans-serif;
|
||||||
|
padding:24px;
|
||||||
|
overflow:auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The phone holds the sethmux app inline (not the floating toolbar).
|
||||||
|
We render a self-contained mobile UI here that mirrors the toolbar
|
||||||
|
visuals exactly, plus the compose row open and Gboard below. */
|
||||||
|
.phone-app{
|
||||||
|
display:flex;flex-direction:column;height:100%;
|
||||||
|
background:#1a1a1d;color:#e8eaed;
|
||||||
|
font-family:'Roboto',sans-serif;
|
||||||
|
}
|
||||||
|
.phone-top{
|
||||||
|
height:44px;flex-shrink:0;
|
||||||
|
background:#202124;border-bottom:1px solid #3c4043;
|
||||||
|
display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:0 14px;
|
||||||
|
}
|
||||||
|
.phone-top .brand{color:#D35400;font-weight:500;font-size:13px}
|
||||||
|
.phone-top .tabs{display:flex;gap:10px;color:#9aa0a6;
|
||||||
|
font-family:'Roboto Mono',ui-monospace,monospace;font-size:11px}
|
||||||
|
.phone-top .tabs b{color:#D35400;font-weight:500}
|
||||||
|
|
||||||
|
.phone-xterm{
|
||||||
|
flex:1;padding:8px 10px;overflow:hidden;background:#1a1a1d;
|
||||||
|
font-family:'Roboto Mono',ui-monospace,Menlo,Consolas,monospace;
|
||||||
|
font-size:12px;line-height:1.5;color:#cfd2d6;
|
||||||
|
}
|
||||||
|
.phone-xterm .ps1{color:#81c995}
|
||||||
|
.phone-xterm .path{color:#8ab4f8}
|
||||||
|
.phone-xterm .git{color:#fdd663}
|
||||||
|
.phone-xterm .cmd{color:#e8eaed}
|
||||||
|
.phone-xterm .dim{color:#5f6368}
|
||||||
|
.phone-xterm .ok{color:#81c995}
|
||||||
|
.phone-xterm .err{color:#f28b82}
|
||||||
|
.phone-xterm .cursor{
|
||||||
|
display:inline-block;width:7px;height:13px;background:#e8eaed;
|
||||||
|
vertical-align:-2px;margin-left:1px;animation:blink 1.1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink{50%{opacity:0}}
|
||||||
|
|
||||||
|
/* ── inline toolbar (same visuals as toolbar.js, but mounted in-tree) ── */
|
||||||
|
.tb{
|
||||||
|
flex-shrink:0;
|
||||||
|
background:#202124;border-top:1px solid #3c4043;
|
||||||
|
padding:6px 8px 7px;
|
||||||
|
box-shadow:0 -1px 0 rgba(0,0,0,.4),0 -8px 24px rgba(0,0,0,.35);
|
||||||
|
display:flex;flex-direction:column;
|
||||||
|
font-family:'Roboto',sans-serif;
|
||||||
|
}
|
||||||
|
.tb .row{display:flex;gap:4px;justify-content:center;align-items:center;width:100%}
|
||||||
|
.tb .row + .row{margin-top:4px}
|
||||||
|
.tb button{
|
||||||
|
background:#303134;color:#e8eaed;border:1px solid #3c4043;
|
||||||
|
border-radius:4px;padding:0 8px;height:32px;min-width:38px;
|
||||||
|
font:500 12px/1 'Roboto',sans-serif;letter-spacing:.1px;
|
||||||
|
display:inline-flex;align-items:center;justify-content:center;
|
||||||
|
}
|
||||||
|
.tb .mono{
|
||||||
|
font-family:'Roboto Mono',ui-monospace,Menlo,Consolas,monospace;
|
||||||
|
font-weight:400;color:#9aa0a6;
|
||||||
|
}
|
||||||
|
.tb .hi{color:#f0a36b;border-color:#5a3a22;background:#2a1f15}
|
||||||
|
.tb .on{background:#D35400;border-color:#D35400;color:#0a0a0a}
|
||||||
|
.tb .grn{color:#81c995}
|
||||||
|
.tb .sep{width:1px;height:20px;background:#3c4043;margin:0 3px;flex-shrink:0}
|
||||||
|
|
||||||
|
.tb .compose{display:flex;width:100%;gap:4px;align-items:center;margin-top:4px}
|
||||||
|
.tb input{
|
||||||
|
flex:1;min-width:0;height:36px;padding:0 10px;
|
||||||
|
background:#303134;color:#e8eaed;
|
||||||
|
border:1px solid #D35400;border-radius:4px;
|
||||||
|
font:400 14px/1 'Roboto Mono',ui-monospace,Menlo,Consolas,monospace;
|
||||||
|
outline:none;-webkit-appearance:none;appearance:none;
|
||||||
|
caret-color:#D35400;
|
||||||
|
}
|
||||||
|
.tb .send{
|
||||||
|
height:36px;min-width:54px;padding:0 12px;
|
||||||
|
background:#D35400;border:1px solid #D35400;color:#0a0a0a;
|
||||||
|
border-radius:4px;font:500 12px/1 'Roboto',sans-serif;
|
||||||
|
}
|
||||||
|
.tb .send.nl{
|
||||||
|
background:#303134;border-color:#3c4043;color:#9aa0a6;
|
||||||
|
min-width:38px;padding:0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* annotation captions next to each phone */
|
||||||
|
.stack{display:flex;gap:32px;align-items:flex-start}
|
||||||
|
.col{display:flex;flex-direction:column;align-items:center;gap:14px}
|
||||||
|
.cap{
|
||||||
|
color:#9aa0a6;font-size:12px;text-align:center;max-width:340px;
|
||||||
|
font-family:'Roboto',sans-serif;
|
||||||
|
}
|
||||||
|
.cap b{color:#e8eaed;font-weight:500;display:block;margin-bottom:4px;font-size:13px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
<script type="text/babel" src="android-frame.jsx"></script>
|
||||||
|
|
||||||
|
<script type="text/babel">
|
||||||
|
|
||||||
|
function PhoneTop({tab='code', n=1, total=4}) {
|
||||||
|
return (
|
||||||
|
<div className="phone-top">
|
||||||
|
<div className="brand">sethmux</div>
|
||||||
|
<div className="tabs"><b>{tab}</b> · {n}/{total}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhoneXterm({collapsed}) {
|
||||||
|
return (
|
||||||
|
<div className="phone-xterm">
|
||||||
|
<div><span className="ps1">seth@mux</span> <span className="path">~</span> <span className="dim">$</span> <span className="cmd">tmux ls</span></div>
|
||||||
|
<div>sethmux: 4 windows</div>
|
||||||
|
<div className="dim"> code git run logs</div>
|
||||||
|
<div> </div>
|
||||||
|
<div><span className="ps1">seth@mux</span> <span className="path">~</span> <span className="dim">$</span> <span className="cmd">make build</span></div>
|
||||||
|
<div className="ok">→ ok in 2.31s</div>
|
||||||
|
{!collapsed && <>
|
||||||
|
<div> </div>
|
||||||
|
<div><span className="ps1">seth@mux</span> <span className="path">~</span> <span className="dim">$</span> <span className="cmd">tail -f logs/run.log</span></div>
|
||||||
|
<div className="dim">[14:02:11] http 200 /api/notify 4ms</div>
|
||||||
|
<div className="dim">[14:02:14] http 200 /api/notify 3ms</div>
|
||||||
|
</>}
|
||||||
|
<div> </div>
|
||||||
|
<div><span className="ps1">seth@mux</span> <span className="path">~</span> <span className="dim">$</span> <span className="cursor"/></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarCollapsed() {
|
||||||
|
return (
|
||||||
|
<div className="tb">
|
||||||
|
<div className="row">
|
||||||
|
<button className="hi">+ Tab</button>
|
||||||
|
<button>Next</button>
|
||||||
|
<button>Prev</button>
|
||||||
|
<div className="sep"/>
|
||||||
|
<button className="mono">^C</button>
|
||||||
|
<button className="mono">^D</button>
|
||||||
|
<button>Clr</button>
|
||||||
|
<div className="sep"/>
|
||||||
|
<button className="mono">Esc</button>
|
||||||
|
<button className="mono">Tab</button>
|
||||||
|
<button className="mono">▲</button>
|
||||||
|
<button className="mono">▼</button>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<button className="hi">Sel</button>
|
||||||
|
<button className="hi">Paste</button>
|
||||||
|
<button>Zoom</button>
|
||||||
|
<button className="grn">Save</button>
|
||||||
|
<div className="sep"/>
|
||||||
|
<button>V.Spl</button>
|
||||||
|
<button>H.Spl</button>
|
||||||
|
<button>Pane</button>
|
||||||
|
<button>Kill</button>
|
||||||
|
<div className="sep"/>
|
||||||
|
<button className="hi">Type</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarComposing() {
|
||||||
|
return (
|
||||||
|
<div className="tb">
|
||||||
|
<div className="row">
|
||||||
|
<button className="hi">+ Tab</button>
|
||||||
|
<button>Next</button>
|
||||||
|
<button>Prev</button>
|
||||||
|
<div className="sep"/>
|
||||||
|
<button className="mono">^C</button>
|
||||||
|
<button className="mono">^D</button>
|
||||||
|
<button>Clr</button>
|
||||||
|
<div className="sep"/>
|
||||||
|
<button className="mono">Esc</button>
|
||||||
|
<button className="mono">Tab</button>
|
||||||
|
<button className="mono">▲</button>
|
||||||
|
<button className="mono">▼</button>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<button className="hi">Sel</button>
|
||||||
|
<button className="hi">Paste</button>
|
||||||
|
<button>Zoom</button>
|
||||||
|
<button className="grn">Save</button>
|
||||||
|
<div className="sep"/>
|
||||||
|
<button>V.Spl</button>
|
||||||
|
<button>H.Spl</button>
|
||||||
|
<button>Pane</button>
|
||||||
|
<button>Kill</button>
|
||||||
|
<div className="sep"/>
|
||||||
|
<button className="on">Type</button>
|
||||||
|
</div>
|
||||||
|
<div className="compose">
|
||||||
|
<input defaultValue="git commit -m "wire up compose bar"" />
|
||||||
|
<button className="send nl">↵</button>
|
||||||
|
<button className="send">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhoneCollapsed() {
|
||||||
|
return (
|
||||||
|
<AndroidDevice width={360} height={720} dark>
|
||||||
|
<div className="phone-app">
|
||||||
|
<PhoneTop tab="code" n={1} total={4}/>
|
||||||
|
<PhoneXterm/>
|
||||||
|
<ToolbarCollapsed/>
|
||||||
|
</div>
|
||||||
|
</AndroidDevice>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhoneComposing() {
|
||||||
|
return (
|
||||||
|
<AndroidDevice width={360} height={720} dark>
|
||||||
|
<div className="phone-app">
|
||||||
|
<PhoneTop tab="git" n={2} total={4}/>
|
||||||
|
<PhoneXterm collapsed/>
|
||||||
|
<ToolbarComposing/>
|
||||||
|
</div>
|
||||||
|
</AndroidDevice>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<div className="col">
|
||||||
|
<PhoneCollapsed/>
|
||||||
|
<div className="cap"><b>Default — 2 rows</b>All chord keys, tabs, splits, and Save in reach. Tap <span style={{color:'#f0a36b'}}>Type</span> to open compose.</div>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<PhoneComposing/>
|
||||||
|
<div className="cap"><b>Compose open — 3 rows</b>Real input field with Gboard autocorrect / swipe / predictions. ↵ or Send flushes to stdin + a newline. Other keys still work mid-typing.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>sethmux — toolbar preview</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&family=Roboto+Mono:wght@400&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#1a1a1d;
|
||||||
|
--bg-toolbar:#202124;
|
||||||
|
--border:#3c4043;
|
||||||
|
--text:#e8eaed;
|
||||||
|
--text-2:#9aa0a6;
|
||||||
|
--accent:#D35400;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html,body{height:100%}
|
||||||
|
body{
|
||||||
|
background:var(--bg);
|
||||||
|
color:var(--text);
|
||||||
|
font:12px/1.4 'Roboto','Helvetica Neue',Arial,sans-serif;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
overflow:hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mock chrome strip so the preview reads like a phone-shell view */
|
||||||
|
.chrome{
|
||||||
|
height:44px;
|
||||||
|
background:var(--bg-toolbar);
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:0 14px;
|
||||||
|
color:var(--text-2);
|
||||||
|
}
|
||||||
|
.chrome .brand{
|
||||||
|
color:var(--accent);
|
||||||
|
font-weight:500;letter-spacing:.2px;font-size:13px;
|
||||||
|
}
|
||||||
|
.chrome .meta{
|
||||||
|
font-family:'Roboto Mono',ui-monospace,monospace;
|
||||||
|
font-size:11px;color:var(--text-2);
|
||||||
|
}
|
||||||
|
.chrome .meta b{color:var(--text);font-weight:400}
|
||||||
|
|
||||||
|
/* Mock xterm pane */
|
||||||
|
.xterm{
|
||||||
|
height:calc(100vh - 44px - 92px);
|
||||||
|
transition:height .15s ease;
|
||||||
|
padding:10px 12px;
|
||||||
|
overflow:hidden;
|
||||||
|
font-family:'Roboto Mono',ui-monospace,Menlo,Consolas,monospace;
|
||||||
|
font-size:13px;line-height:1.45;
|
||||||
|
color:#cfd2d6;
|
||||||
|
}
|
||||||
|
.xterm .ps1{color:#81c995}
|
||||||
|
.xterm .path{color:#8ab4f8}
|
||||||
|
.xterm .git{color:#fdd663}
|
||||||
|
.xterm .cmd{color:#e8eaed}
|
||||||
|
.xterm .dim{color:#5f6368}
|
||||||
|
.xterm .cursor{
|
||||||
|
display:inline-block;width:8px;height:15px;
|
||||||
|
background:#e8eaed;vertical-align:-2px;margin-left:1px;
|
||||||
|
animation:blink 1.1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink{50%{opacity:0}}
|
||||||
|
.xterm-screen{height:100%}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="chrome">
|
||||||
|
<div class="brand">sethmux</div>
|
||||||
|
<div class="meta"><b>code</b> · <b>1</b>/4 · 80×24</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="xterm">
|
||||||
|
<div class="xterm-screen">
|
||||||
|
<div><span class="ps1">seth@mux</span> <span class="path">~/sethmux</span> <span class="git">(main)</span> <span class="dim">$</span> <span class="cmd">tmux ls</span></div>
|
||||||
|
<div>sethmux: 4 windows (created Thu Apr 24 09:12:03 2026) (attached)</div>
|
||||||
|
<div class="dim"> code git run logs</div>
|
||||||
|
<div> </div>
|
||||||
|
<div><span class="ps1">seth@mux</span> <span class="path">~/sethmux</span> <span class="git">(main)</span> <span class="dim">$</span> <span class="cmd">make build && sethmux-notify "OK"</span></div>
|
||||||
|
<div class="dim">→ built in 2.31s</div>
|
||||||
|
<div> </div>
|
||||||
|
<div><span class="ps1">seth@mux</span> <span class="path">~/sethmux</span> <span class="git">(main)</span> <span class="dim">$</span> <span class="cursor"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Force the toolbar visible in preview at any width -->
|
||||||
|
<style>#mb{display:flex !important}</style>
|
||||||
|
<script src="toolbar.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
(function(){
|
||||||
|
if(window._toolbar) return;
|
||||||
|
window._toolbar=true;
|
||||||
|
|
||||||
|
// ── Sethmux toolbar — Google Workspace dark vibe, sethmux orange accent ──
|
||||||
|
// Tokens (kept local, not :root, so we don't pollute the host page)
|
||||||
|
// bg #202124 toolbar surface
|
||||||
|
// surface #303134 button face
|
||||||
|
// border #3c4043 hairlines
|
||||||
|
// text #e8eaed primary
|
||||||
|
// text-2 #9aa0a6 secondary / icons at rest
|
||||||
|
// accent #D35400 sethmux orange (replaces Google blue)
|
||||||
|
// accent-bg #3a2a1a tinted hover/selected wash
|
||||||
|
// ok #81c995 save success
|
||||||
|
|
||||||
|
var css=document.createElement('style');
|
||||||
|
css.textContent = [
|
||||||
|
"#mb{",
|
||||||
|
"display:none;position:fixed;bottom:0;left:0;right:0;",
|
||||||
|
"background:#202124;",
|
||||||
|
"border-top:1px solid #3c4043;",
|
||||||
|
"padding:6px 8px 7px;",
|
||||||
|
"gap:0;flex-direction:column;align-items:stretch;",
|
||||||
|
"z-index:99999;",
|
||||||
|
"font-family:'Roboto','Helvetica Neue',Arial,sans-serif;",
|
||||||
|
"-webkit-font-smoothing:antialiased;",
|
||||||
|
"box-shadow:0 -1px 0 rgba(0,0,0,.4),0 -8px 24px rgba(0,0,0,.35);",
|
||||||
|
"}",
|
||||||
|
|
||||||
|
"#mb .row{",
|
||||||
|
"display:flex;gap:4px;justify-content:center;align-items:center;",
|
||||||
|
"width:100%;",
|
||||||
|
"}",
|
||||||
|
"#mb .row + .row{margin-top:4px}",
|
||||||
|
|
||||||
|
"#mb button{",
|
||||||
|
"background:#303134;",
|
||||||
|
"color:#e8eaed;",
|
||||||
|
"border:1px solid #3c4043;",
|
||||||
|
"border-radius:4px;",
|
||||||
|
"padding:0 10px;height:32px;min-width:40px;",
|
||||||
|
"font:500 12px/1 'Roboto','Helvetica Neue',Arial,sans-serif;",
|
||||||
|
"letter-spacing:.1px;",
|
||||||
|
"cursor:pointer;",
|
||||||
|
"touch-action:manipulation;",
|
||||||
|
"-webkit-tap-highlight-color:transparent;",
|
||||||
|
"user-select:none;",
|
||||||
|
"display:inline-flex;align-items:center;justify-content:center;",
|
||||||
|
"transition:background .15s ease,border-color .15s ease,color .15s ease;",
|
||||||
|
"}",
|
||||||
|
"#mb button:hover{background:#3a2a1a;border-color:#3c4043;color:#fff}",
|
||||||
|
"#mb button:active{background:#D35400;border-color:#D35400;color:#0a0a0a}",
|
||||||
|
|
||||||
|
// Mono labels for chord/arrow keys so they read as terminal input
|
||||||
|
"#mb button.mono{",
|
||||||
|
"font-family:'Roboto Mono','SF Mono',ui-monospace,Menlo,Consolas,monospace;",
|
||||||
|
"font-weight:400;color:#9aa0a6;",
|
||||||
|
"}",
|
||||||
|
"#mb button.mono:hover{color:#e8eaed}",
|
||||||
|
|
||||||
|
// Accent (orange) — used for primary/important actions at rest
|
||||||
|
"#mb button.hi{",
|
||||||
|
"color:#f0a36b;border-color:#5a3a22;background:#2a1f15;",
|
||||||
|
"}",
|
||||||
|
"#mb button.hi:hover{background:#3a2a1a;color:#ffb37a;border-color:#7a4a2a}",
|
||||||
|
|
||||||
|
// Toggled-on state (Sel active, etc.) — filled accent
|
||||||
|
"#mb button.on{",
|
||||||
|
"background:#D35400;border-color:#D35400;color:#0a0a0a;",
|
||||||
|
"}",
|
||||||
|
"#mb button.on:hover{background:#e26416;border-color:#e26416;color:#0a0a0a}",
|
||||||
|
|
||||||
|
// Success (Save) — Google green at rest, fills on confirm
|
||||||
|
"#mb button.grn{color:#81c995;border-color:#3c4043;background:#303134}",
|
||||||
|
"#mb button.grn:hover{background:#1f2a22;color:#a8e0b8;border-color:#3a5a44}",
|
||||||
|
"#mb button.grn.on{background:#1e8e3e;border-color:#1e8e3e;color:#0a0a0a}",
|
||||||
|
|
||||||
|
// Vertical hairline divider between groups
|
||||||
|
"#mb .sep{",
|
||||||
|
"width:1px;height:20px;background:#3c4043;margin:0 4px;flex-shrink:0;",
|
||||||
|
"}",
|
||||||
|
|
||||||
|
// ── Compose bar (mobile autocorrect workaround) ──
|
||||||
|
"#mb .compose{",
|
||||||
|
"display:none;width:100%;gap:4px;align-items:center;margin-top:4px;",
|
||||||
|
"}",
|
||||||
|
"#mb.composing .compose{display:flex}",
|
||||||
|
"#mb.composing #typebtn{",
|
||||||
|
"background:#D35400;border-color:#D35400;color:#0a0a0a;",
|
||||||
|
"}",
|
||||||
|
"#mb-compose{",
|
||||||
|
"flex:1;min-width:0;height:36px;",
|
||||||
|
"padding:0 10px;",
|
||||||
|
"background:#303134;color:#e8eaed;",
|
||||||
|
"border:1px solid #3c4043;border-radius:4px;",
|
||||||
|
"font:400 14px/1 'Roboto Mono','SF Mono',ui-monospace,Menlo,Consolas,monospace;",
|
||||||
|
"outline:none;",
|
||||||
|
"-webkit-appearance:none;appearance:none;",
|
||||||
|
"caret-color:#D35400;",
|
||||||
|
"}",
|
||||||
|
"#mb-compose:focus{border-color:#D35400}",
|
||||||
|
"#mb-compose::placeholder{color:#5f6368}",
|
||||||
|
"#mb .send{",
|
||||||
|
"height:36px;min-width:54px;padding:0 12px;",
|
||||||
|
"background:#D35400;border:1px solid #D35400;color:#0a0a0a;",
|
||||||
|
"border-radius:4px;font:500 12px/1 'Roboto',sans-serif;cursor:pointer;",
|
||||||
|
"}",
|
||||||
|
"#mb .send:disabled{background:#303134;border-color:#3c4043;color:#5f6368;cursor:default}",
|
||||||
|
"#mb .send.nl{background:#303134;border-color:#3c4043;color:#9aa0a6;min-width:38px;padding:0 8px}",
|
||||||
|
"#mb .send.nl:hover{color:#e8eaed;background:#3a2a1a}",
|
||||||
|
|
||||||
|
// Selection mode visual — dim the terminal slightly so the text-select layer reads
|
||||||
|
"body.selmode .xterm-screen{",
|
||||||
|
"pointer-events:none!important;",
|
||||||
|
"user-select:text!important;-webkit-user-select:text!important;",
|
||||||
|
"}",
|
||||||
|
"body.selmode .xterm{filter:brightness(.92)}",
|
||||||
|
|
||||||
|
"@media(max-width:900px){#mb{display:flex}}",
|
||||||
|
// Tighter on very narrow phones
|
||||||
|
"@media(max-width:380px){",
|
||||||
|
"#mb button{padding:0 7px;min-width:34px;height:30px;font-size:11.5px}",
|
||||||
|
"#mb .sep{margin:0 2px}",
|
||||||
|
"}",
|
||||||
|
].join("");
|
||||||
|
document.head.appendChild(css);
|
||||||
|
|
||||||
|
var bar=document.createElement('div');
|
||||||
|
bar.id='mb';
|
||||||
|
bar.innerHTML =
|
||||||
|
'<div class="row">' +
|
||||||
|
'<button class="hi" data-k="\x01c">+ Tab</button>' +
|
||||||
|
'<button data-k="\x01n">Next</button>' +
|
||||||
|
'<button data-k="\x01p">Prev</button>' +
|
||||||
|
'<div class="sep"></div>' +
|
||||||
|
'<button class="mono" data-k="\x03">^C</button>' +
|
||||||
|
'<button class="mono" data-k="\x04">^D</button>' +
|
||||||
|
'<button data-k="\x0c">Clr</button>' +
|
||||||
|
'<div class="sep"></div>' +
|
||||||
|
'<button class="mono" data-k="\x1b">Esc</button>' +
|
||||||
|
'<button class="mono" data-k="\t">Tab</button>' +
|
||||||
|
'<button class="mono" data-k="\x1bOA">\u25B2</button>' +
|
||||||
|
'<button class="mono" data-k="\x1bOB">\u25BC</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="row">' +
|
||||||
|
'<button class="hi" id="selbtn" data-sel="1">Sel</button>' +
|
||||||
|
'<button class="hi" id="pastebtn" data-paste="1">Paste</button>' +
|
||||||
|
'<button data-k="\x01z">Zoom</button>' +
|
||||||
|
'<button class="grn" data-save="1">Save</button>' +
|
||||||
|
'<div class="sep"></div>' +
|
||||||
|
'<button data-k="\x01v">V.Spl</button>' +
|
||||||
|
'<button data-k="\x01s">H.Spl</button>' +
|
||||||
|
'<button data-k="\x01o">Pane</button>' +
|
||||||
|
'<button data-k="\x01x">Kill</button>' +
|
||||||
|
'<div class="sep"></div>' +
|
||||||
|
'<button class="hi" id="typebtn" data-type="1">Type</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="compose row">' +
|
||||||
|
'<input id="mb-compose" type="text" ' +
|
||||||
|
'placeholder="type here \u2014 autocorrect on, \u21B5 sends" ' +
|
||||||
|
'autocomplete="on" autocorrect="on" autocapitalize="sentences" ' +
|
||||||
|
'spellcheck="true" enterkeyhint="send" inputmode="text" />' +
|
||||||
|
'<button class="send nl" data-nl="1" title="Send newline">\u21B5</button>' +
|
||||||
|
'<button class="send" data-send="1">Send</button>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(bar);
|
||||||
|
|
||||||
|
// ── terminal I/O ─────────────────────────────────────────
|
||||||
|
function send(k){
|
||||||
|
if(document.body.classList.contains('selmode')) toggleSel();
|
||||||
|
var t=window.term;
|
||||||
|
if(!t) return;
|
||||||
|
if(t._core && t._core.coreService && t._core.coreService.triggerDataEvent){
|
||||||
|
t._core.coreService.triggerDataEvent(k);
|
||||||
|
} else if(t._core && t._core._onData){
|
||||||
|
t._core._onData.fire(k);
|
||||||
|
} else if(t.input){
|
||||||
|
t.input(k);
|
||||||
|
}
|
||||||
|
t.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSel(){
|
||||||
|
var b=document.getElementById('selbtn');
|
||||||
|
document.body.classList.toggle('selmode');
|
||||||
|
if(document.body.classList.contains('selmode')){
|
||||||
|
b.classList.add('on');b.textContent='Done';
|
||||||
|
} else {
|
||||||
|
b.classList.remove('on');b.textContent='Sel';
|
||||||
|
window.getSelection().removeAllRanges();
|
||||||
|
if(window.term) window.term.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doPaste(){
|
||||||
|
if(!navigator.clipboard||!navigator.clipboard.readText){
|
||||||
|
alert('Clipboard access not available (needs HTTPS)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.readText().then(function(text){
|
||||||
|
if(text) send(text);
|
||||||
|
}).catch(function(e){
|
||||||
|
alert('Clipboard read failed: '+e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSave(){
|
||||||
|
send('\x01S');
|
||||||
|
var btn=document.querySelector('[data-save]');
|
||||||
|
btn.classList.add('on');
|
||||||
|
btn.textContent='\u2713 Saved';
|
||||||
|
setTimeout(function(){btn.classList.remove('on');btn.textContent='Save';},1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compose bar (mobile autocorrect workaround) ──
|
||||||
|
// xterm.js reads keys from a hidden textarea via per-keystroke events;
|
||||||
|
// Gboard autocorrect/swipe replaces .value in bulk so those chars never
|
||||||
|
// reach stdin. The compose bar gives autocorrect a real <input> to chew
|
||||||
|
// on, then sends the assembled string to the terminal in one shot.
|
||||||
|
var ci=document.getElementById('mb-compose');
|
||||||
|
|
||||||
|
function toggleType(){
|
||||||
|
var on=bar.classList.toggle('composing');
|
||||||
|
if(on){
|
||||||
|
// Defer focus so the keyboard opens reliably on iOS/Android
|
||||||
|
setTimeout(function(){ ci.focus(); },0);
|
||||||
|
relayout();
|
||||||
|
} else {
|
||||||
|
ci.blur();
|
||||||
|
if(window.term) window.term.focus();
|
||||||
|
relayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushCompose(appendNewline){
|
||||||
|
var v=ci.value;
|
||||||
|
ci.value='';
|
||||||
|
if(v) send(v);
|
||||||
|
if(appendNewline) send('\r');
|
||||||
|
ci.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
ci.addEventListener('keydown',function(e){
|
||||||
|
if(e.key==='Enter'){
|
||||||
|
e.preventDefault();
|
||||||
|
flushCompose(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bar.addEventListener('click',function(e){
|
||||||
|
var btn=e.target.closest('button');
|
||||||
|
if(!btn) return;
|
||||||
|
if(btn.dataset.sel) return toggleSel();
|
||||||
|
if(btn.dataset.paste) return doPaste();
|
||||||
|
if(btn.dataset.save) return doSave();
|
||||||
|
if(btn.dataset.type) return toggleType();
|
||||||
|
if(btn.dataset.send) return flushCompose(true);
|
||||||
|
if(btn.dataset.nl) return flushCompose(true); // explicit newline button
|
||||||
|
if(btn.dataset.k) {
|
||||||
|
// Key pressed while composing: flush typed text first so order is preserved,
|
||||||
|
// but DON'T close the compose bar — user is mid-thought.
|
||||||
|
if(bar.classList.contains('composing') && ci.value){
|
||||||
|
var v=ci.value; ci.value=''; send(v);
|
||||||
|
}
|
||||||
|
send(btn.dataset.k);
|
||||||
|
if(bar.classList.contains('composing')) ci.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep terminal focused when tapping outside the toolbar
|
||||||
|
document.addEventListener('click', function(e){
|
||||||
|
if(!e.target.closest('#mb') && window.term) window.term.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab key shouldn't escape into toolbar focus
|
||||||
|
bar.querySelectorAll('button').forEach(function(b){ b.tabIndex = -1; });
|
||||||
|
|
||||||
|
// Disable mobile autocomplete on xterm's hidden textarea
|
||||||
|
function disableMobileAutocomplete(){
|
||||||
|
var ta=document.querySelector('.xterm-helper-textarea');
|
||||||
|
if(!ta || ta.dataset.acOff) return;
|
||||||
|
ta.setAttribute('autocomplete','off');
|
||||||
|
ta.setAttribute('autocorrect','off');
|
||||||
|
ta.setAttribute('autocapitalize','off');
|
||||||
|
ta.setAttribute('spellcheck','false');
|
||||||
|
ta.dataset.acOff='1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize terminal to leave room for the toolbar on mobile.
|
||||||
|
// Height is dynamic: 2 rows when collapsed, 3 when compose bar is open.
|
||||||
|
function relayout(){
|
||||||
|
var el=document.querySelector('.xterm');
|
||||||
|
if(!el || window.innerWidth>900) return;
|
||||||
|
var h=bar.offsetHeight || 92;
|
||||||
|
el.style.height='calc(100vh - '+(h+4)+'px)';
|
||||||
|
if(window.term && window.term.fit) window.term.fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
var obs=new MutationObserver(function(){
|
||||||
|
disableMobileAutocomplete();
|
||||||
|
relayout();
|
||||||
|
});
|
||||||
|
obs.observe(document.body,{childList:true,subtree:true});
|
||||||
|
window.addEventListener('resize',relayout);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user