diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 00000000..57df9e30 --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,281 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-05-11T00:00:00Z", + "title": "Design System: MouseTerm", + "extensions": { + "colorMeta": { + "app-bg": { + "role": "neutral", + "displayName": "App Background (Host Sidebar)", + "canonical": "var(--vscode-sideBar-background)", + "tonalRamp": ["#0d0905", "#150e07", "#1a1309", "#221a0f", "#2b2114", "#3a2c1a", "#5e452e", "#8a6843"] + }, + "app-fg": { + "role": "neutral", + "displayName": "App Foreground (Host Sidebar Text)", + "canonical": "var(--vscode-sideBar-foreground)", + "tonalRamp": ["#3a2c1a", "#5e452e", "#8a6843", "#a98968", "#c7a07d", "#d3af86", "#e3c5a3", "#efdac4"] + }, + "surface-raised": { + "role": "neutral", + "displayName": "Raised Surface (Popovers, Dialogs)", + "canonical": "var(--vscode-editorWidget-background)", + "tonalRamp": ["#15100a", "#1d150c", "#241a10", "#2b2114", "#33271a", "#3e3020", "#5e452e", "#8a6843"] + }, + "foreground": { + "role": "neutral", + "displayName": "Primary Foreground", + "canonical": "var(--vscode-editor-foreground)", + "tonalRamp": ["#3a2c1a", "#5e452e", "#8a6843", "#a98968", "#c7a07d", "#d3af86", "#e3c5a3", "#efdac4"] + }, + "muted": { + "role": "neutral", + "displayName": "Muted Foreground (Shortcuts, Hints)", + "canonical": "var(--vscode-descriptionForeground)", + "tonalRamp": ["#3a2c1a", "#5e452e", "#776046", "#8a6843", "#9a7a52", "#a98968", "#c7a07d", "#d3af86"] + }, + "border": { + "role": "neutral", + "displayName": "Panel Border (Hairline on Raised Surfaces)", + "canonical": "var(--vscode-panel-border)", + "tonalRamp": ["#221a0f", "#2b2114", "#3a2c1a", "#4a3823", "#5e452e", "#6f5538", "#8a6843", "#a98968"] + }, + "header-active-bg": { + "role": "primary", + "displayName": "Focused Header (the closest thing to an accent)", + "canonical": "var(--vscode-list-activeSelectionBackground)", + "tonalRamp": ["#2e2410", "#3e3018", "#523f20", "#665128", "#7e602f", "#9a7638", "#b58c45", "#cda35a"] + }, + "header-active-fg": { + "role": "primary", + "displayName": "Focused Header Text", + "canonical": "var(--vscode-list-activeSelectionForeground)", + "tonalRamp": ["#8a6843", "#a98968", "#c7a07d", "#d3af86", "#e3c5a3", "#efdac4", "#f5e8d6", "#ffffff"] + }, + "header-inactive-bg": { + "role": "secondary", + "displayName": "Unfocused Header", + "canonical": "var(--vscode-list-inactiveSelectionBackground)", + "tonalRamp": ["#221a0f", "#2b2114", "#3a2c1a", "#4a3823", "#5e452e", "#6f5538", "#8a6843", "#a98968"] + }, + "header-inactive-fg": { + "role": "secondary", + "displayName": "Unfocused Header Text", + "canonical": "var(--vscode-list-inactiveSelectionForeground)", + "tonalRamp": ["#5e452e", "#8a6843", "#a98968", "#c7a07d", "#d3af86", "#e3c5a3", "#efdac4", "#f5e8d6"] + }, + "door-bg": { + "role": "tertiary", + "displayName": "Door (runtime-picked for OKLab separation)", + "canonical": "var(--color-door-bg)", + "tonalRamp": ["#221a0f", "#2b2114", "#3a2c1a", "#4a3823", "#5e452e", "#6f5538", "#8a6843", "#a98968"] + }, + "door-fg": { + "role": "tertiary", + "displayName": "Door Text", + "canonical": "var(--color-door-fg)", + "tonalRamp": ["#5e452e", "#8a6843", "#a98968", "#c7a07d", "#d3af86", "#e3c5a3", "#efdac4", "#f5e8d6"] + }, + "focus-ring": { + "role": "tertiary", + "displayName": "Focus Ring (runtime-picked for chroma)", + "canonical": "var(--color-focus-ring)", + "tonalRamp": ["#5e452e", "#8a6843", "#a98968", "#c7a07d", "#d3af86", "#e3c5a3", "#f9d181", "#fee9b3"] + }, + "terminal-bg": { + "role": "neutral", + "displayName": "Terminal Background (orthogonal to chrome)", + "canonical": "var(--vscode-terminal-background)", + "tonalRamp": ["#0d0905", "#150e07", "#1a1309", "#221a0f", "#2b2114", "#3a2c1a", "#4a3823", "#5e452e"] + }, + "terminal-fg": { + "role": "neutral", + "displayName": "Terminal Foreground", + "canonical": "var(--vscode-terminal-foreground)", + "tonalRamp": ["#5e452e", "#8a6843", "#a98968", "#c7a07d", "#d3af86", "#e3c5a3", "#efdac4", "#f5e8d6"] + }, + "input-bg": { + "role": "neutral", + "displayName": "Input Background", + "canonical": "var(--vscode-input-background)", + "tonalRamp": ["#15100a", "#1d150c", "#241a10", "#2b2114", "#33271a", "#3e3020", "#5e452e", "#8a6843"] + }, + "input-border": { + "role": "neutral", + "displayName": "Input Border", + "canonical": "var(--vscode-input-border)", + "tonalRamp": ["#221a0f", "#2b2114", "#3a2c1a", "#4a3823", "#5e452e", "#6f5538", "#8a6843", "#a98968"] + }, + "error": { + "role": "primary", + "displayName": "Error / Destructive (terminal ANSI red)", + "canonical": "var(--vscode-terminal-ansiRed)", + "tonalRamp": ["#3d1e10", "#5a2c17", "#75381e", "#984828", "#cc6633", "#d97f4f", "#e29a72", "#ecbfa1"] + }, + "success": { + "role": "primary", + "displayName": "Success / Affirm (terminal ANSI green)", + "canonical": "var(--vscode-terminal-ansiGreen)", + "tonalRamp": ["#293119", "#3d4824", "#52602f", "#6a7a3a", "#889b4a", "#9eaf60", "#b5c280", "#cdd6a4"] + }, + "alarm": { + "role": "primary", + "displayName": "Alarm / Bell (terminal ANSI yellow, runtime-rotated per surface)", + "canonical": "var(--vscode-terminal-ansiYellow)", + "tonalRamp": ["#4a3818", "#6e5424", "#917030", "#b58c45", "#d3a85c", "#f9d181", "#fbdda3", "#fee9b3"] + }, + "window-close-hover": { + "role": "primary", + "displayName": "Window Close Hover (native OS red, the only literal)", + "canonical": "#b92a1b", + "tonalRamp": ["#3d0e09", "#5a160d", "#751c12", "#982618", "#b92a1b", "#cf493a", "#dd6e60", "#e89889"] + } + }, + "typographyMeta": { + "body": { + "displayName": "Body (the workhorse step)", + "purpose": "Pane headers, doors, button labels, popup contents. Almost everything sits here." + }, + "label": { + "displayName": "Label (tiny, wide tracking)", + "purpose": "TODO pills, kill-confirm hints, anything that has to be readable at 10px." + }, + "shortcut": { + "displayName": "Shortcut [bracketed]", + "purpose": "Keybinding hints, always rendered through or renderShortcuts()." + } + }, + "shadows": [ + { "name": "popover", "value": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", "purpose": "Tooltips, selection popup, header tooltips. Surface is in the air, not heavy." }, + { "name": "dialog", "value": "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", "purpose": "Kill-confirm sheet, TODO alert dialog. Floats above chrome." }, + { "name": "modal", "value": "0 25px 50px -12px rgb(0 0 0 / 0.25)", "purpose": "Theme picker dropdown, theme debugger, theme store. Top-of-app surface." }, + { "name": "inset-focus-ring", "value": "inset 0 0 0 1px var(--color-focus-ring)", "purpose": "Mobile segmented-control selected state. Inset because a border would shift layout on toggle." }, + { "name": "inset-border", "value": "inset 0 0 0 1px var(--color-border)", "purpose": "Mobile segmented-control container. Same layout-stability reason." } + ], + "motion": [ + { "name": "ease-quart-out", "value": "cubic-bezier(0.22, 1, 0.36, 1)", "purpose": "Default for pane spawn/kill and any layout-adjacent transition. Exponential ease-out, no bounce." }, + { "name": "spring-overshoot", "value": "cubic-bezier(0.34, 1.56, 0.64, 1)", "purpose": "Reserved for celebratory state-resolution: TODO check pop-in, kill-confirm letter flash, copy-confirm scale. Used only at 220–500ms; never on layout." }, + { "name": "ease-in-out-bell", "value": "ease-in-out", "purpose": "Bell ring continuous animation (800ms, infinite, alternating ±45deg rotation)." }, + { "name": "ease-out-shake", "value": "ease-out", "purpose": "Kill-overlay shake-x (400ms, x-axis only)." }, + { "name": "duration-pane", "value": "440ms", "purpose": "Pane spawn and kill choreography." }, + { "name": "duration-flourish", "value": "500ms", "purpose": "TODO pill dismiss sequence." }, + { "name": "duration-confirm", "value": "220ms", "purpose": "Kill-overlay confirm letter flash." }, + { "name": "duration-copy-flash", "value": "260ms", "purpose": "Selection popup copy-confirm scale flash." }, + { "name": "duration-bell-ring", "value": "800ms", "purpose": "One full ring cycle." }, + { "name": "duration-shake", "value": "400ms", "purpose": "Kill-cancel shake-x." } + ], + "breakpoints": [ + { "name": "md", "value": "768px" } + ] + }, + "components": [ + { + "name": "Door", + "kind": "custom", + "refersTo": "door", + "description": "Pane-header indicator on the baseboard. Top corners rounded, square bottom; the signature component of the system.", + "html": "", + "css": ".ds-door { display: flex; align-items: center; gap: 8px; height: 24px; min-width: 68px; max-width: 220px; padding: 0 10px; overflow: hidden; border: none; border-radius: 8px 8px 0 0; background: var(--color-door-bg, #5e452e); color: var(--color-door-fg, #d3af86); font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 0.75rem; line-height: 1rem; font-weight: 500; cursor: pointer; } .ds-door__title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } .ds-door__badges { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .ds-door__bell { color: var(--vscode-terminal-ansiYellow, #f9d181); }" + }, + { + "name": "Pane Header (Active)", + "kind": "nav", + "refersTo": "header-active-bg", + "description": "Focused pane header tab. The bg swap between this and the inactive variant is the entire focus affordance.", + "html": "
~/src/lib · pnpm dev
", + "css": ".ds-pane-header { display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 10px; border-radius: 8px 8px 0 0; font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 0.75rem; font-weight: 500; } .ds-pane-header--active { background: var(--vscode-list-activeSelectionBackground, #7e602f); color: var(--vscode-list-activeSelectionForeground, #ffffff); } .ds-pane-header__title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ds-pane-header__actions { display: flex; gap: 2px; flex-shrink: 0; } .ds-header-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; border: none; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; transition: background 120ms cubic-bezier(0.22, 1, 0.36, 1); } .ds-header-btn:hover { background: color-mix(in srgb, currentColor 10%, transparent); } .ds-header-btn:focus-visible { outline: 1px solid var(--color-focus-ring, #f9d181); outline-offset: 1px; }" + }, + { + "name": "Pane Header (Inactive)", + "kind": "nav", + "refersTo": "header-inactive-bg", + "description": "Unfocused pane header. Identical geometry to active; only the background and foreground differ.", + "html": "
~/src/web · vite preview
", + "css": ".ds-pane-header--inactive { background: var(--vscode-list-inactiveSelectionBackground, #5e452e); color: var(--vscode-list-inactiveSelectionForeground, #d3af86); }" + }, + { + "name": "Window Close Button", + "kind": "button", + "refersTo": "chrome-button-window-close", + "description": "Windows/Linux native-style window close. The one place a literal hex (#b92a1b) is allowed in the system, because the platform convention is a hard red.", + "html": "", + "css": ".ds-win-close { display: flex; align-items: center; justify-content: center; width: 44px; height: 20px; padding: 0; border: none; background: transparent; color: inherit; cursor: pointer; transition: background 120ms cubic-bezier(0.22, 1, 0.36, 1); } .ds-win-close:hover { background: #b92a1b; color: #ffffff; }" + }, + { + "name": "Tooltip / Popup Button Row", + "kind": "card", + "refersTo": "popup-button-row", + "description": "The raised-surface primitive. Used as a tooltip body, a selection popup, and an illegal-rename warning. Always carries the popover shadow.", + "html": "
Kill pane[⌘W]
", + "css": ".ds-popup { display: inline-flex; flex-direction: column; gap: 2px; padding: 6px 8px; background: var(--vscode-editorWidget-background, #2b2114); color: var(--vscode-editor-foreground, #d3af86); border: 1px solid var(--vscode-panel-border, #5e452e); border-radius: 4px; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 0.75rem; line-height: 1rem; font-weight: 500; white-space: nowrap; } .ds-popup__line { display: block; } .ds-shortcut { color: var(--vscode-descriptionForeground, #a98968); }" + }, + { + "name": "Kill Confirm Dialog", + "kind": "card", + "refersTo": "kill-confirm-dialog", + "description": "The kill-confirmation sheet that appears centered over a focused pane. Uses surface-raised + dialog shadow + the spring-overshoot curve when the user confirms.", + "html": "
Kill pnpm dev?
[y] confirm [n] cancel
", + "css": ".ds-kill { display: inline-flex; flex-direction: column; gap: 6px; padding: 16px 24px; background: var(--vscode-editorWidget-background, #2b2114); color: var(--vscode-editor-foreground, #d3af86); border: 1px solid var(--vscode-panel-border, #5e452e); border-radius: 8px; box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 0.75rem; text-align: center; } .ds-kill__title { font-weight: 500; } .ds-kill__name { color: var(--vscode-terminal-ansiRed, #cc6633); font-weight: 600; } .ds-kill__hint { color: var(--vscode-descriptionForeground, #a98968); font-size: 0.625rem; }" + }, + { + "name": "TODO Pill", + "kind": "chip", + "refersTo": "todo-pill", + "description": "Tiny inline label inside pane headers and doors. The wider tracking (0.08em) is mandatory at this 10px size; without it the letters smear.", + "html": "TODO", + "css": ".ds-todo-pill { display: inline-block; padding: 0 2px; font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 0.625rem; line-height: 1rem; font-weight: 600; letter-spacing: 0.08em; color: var(--vscode-editor-foreground, #d3af86); }" + }, + { + "name": "Theme Picker Trigger", + "kind": "button", + "refersTo": "header-action-button-labeled", + "description": "The labeled chrome button used to open the theme picker. Inherits header fg; hovers with a 10% wash of currentColor so it works under any theme.", + "html": "", + "css": ".ds-trigger { display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 6px; border: none; border-radius: 4px; background: transparent; color: inherit; font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 0.625rem; font-weight: 500; cursor: pointer; transition: background 120ms cubic-bezier(0.22, 1, 0.36, 1); } .ds-trigger:hover { background: color-mix(in srgb, currentColor 10%, transparent); } .ds-trigger:focus-visible { outline: 1px solid var(--color-focus-ring, #f9d181); outline-offset: 1px; }" + } + ], + "narrative": { + "northStar": "The Native Tenant", + "overview": "MouseTerm is a tenant in someone else's house. The house is VSCode. The user picked the furniture (their theme), the lighting (their mode), the typography (their editor font). MouseTerm moves in, multiplies what the user can do with their terminals, and leaves the decor alone. The system is intentionally minimal and bg-only. Chrome recedes; terminals are the content. Hierarchy is conveyed through background shifts between header-active-bg and header-inactive-bg, not through borders, shadows, or accent stripes. Status is conveyed through shape and position and through the active terminal palette's own ANSI red/green/yellow, not through a separate design-system palette.", + "keyCharacteristics": [ + "Host-theme-driven palette: every color is a var(--vscode-*) passthrough.", + "Bg-only chrome: no decorative borders, no resting shadows, no accent stripes.", + "Monospace everywhere: sans and mono resolve to the same VSCode editor font.", + "Tight type scale: text-xs (10px) and text-sm (12px) override Tailwind defaults; almost everything sits on these two steps.", + "Status through palette already in the room: alerts use the user's ANSI red / green / yellow, not a brand color.", + "Motion: short, exponential ease-out for layout transitions; sparing spring-overshoot reserved for celebratory state-resolution." + ], + "rules": [ + { "name": "The Host-Theme-Only Rule", "body": "Never write a hex value or oklch() literal into theme.css or a component. Never use var(..., fallback) chains. Every color must resolve through --vscode-* or one of the body-published runtime picks. The one allowed exception is #b92a1b for native window-close hover.", "section": "colors" }, + { "name": "The Bg-Only Chrome Rule", "body": "Pane headers, doors, and the baseboard convey hierarchy through background shifts only. Do not add borders or shadows to 'make the hierarchy work.'", "section": "colors" }, + { "name": "The Active Header Doubles as Accent Rule", "body": "Animated emphasis (copy-confirm flash, active-pane selection ring) tints with header-active-bg/25. The accent is not separate from the focus signal; they are the same color, used at different opacities.", "section": "colors" }, + { "name": "The Two-Step Rule", "body": "Almost everything sits on text-xs or text-sm. If a new surface wants text-base or larger, the surface is probably wrong.", "section": "typography" }, + { "name": "The Bracket-Shortcut Rule", "body": "Keybindings always render as [k] in muted color via or renderShortcuts(...). Never put Ctrl+K or ⌘K in chrome text.", "section": "typography" }, + { "name": "The Flat-At-Rest Rule", "body": "Surfaces in the resting layout (panes, doors, baseboard, terminal content) carry no shadow. Shadow appears only when a surface enters the air.", "section": "elevation" }, + { "name": "The Inset-Over-Border Rule", "body": "When a surface needs a 1px stroke that may toggle on state change, prefer shadow-[inset_0_0_0_1px_…] over border. The shadow does not affect layout; the border does.", "section": "elevation" } + ], + "dos": [ + "Do route every color through a --vscode-* variable or a body-published runtime pick (--color-door-*, --color-focus-ring, --color-alarm-vs-*). The frontmatter is the contract.", + "Do keep chrome on text-xs (10px) and text-sm (12px). The Tailwind defaults are overridden for a reason; do not write text-base into chrome to 'make it readable.'", + "Do use hover:bg-current/10 for neutral hover feedback inside theme-tinted chrome. It is the one hover treatment that always works.", + "Do convey active-vs-inactive pane state through the header-active-bg / header-inactive-bg background swap. That is the entire focus affordance.", + "Do render keybindings as [k] via / renderShortcuts. Always muted, always bracketed.", + "Do gate every keyframe animation behind @media (prefers-reduced-motion: reduce).", + "Do use shadow-[inset_0_0_0_1px_var(--color-…)] instead of border when a stroke may toggle on state change.", + "Do treat doors as the navigation primitive. Doors own rounded-t-lg; terminal bodies own rounded-b-lg; together they form one rectangle.", + "Do use the spring-overshoot curve cubic-bezier(0.34, 1.56, 0.64, 1) only for state-resolution moments (TODO check, kill confirm, copy flash), and keep durations short (220-500ms)." + ], + "donts": [ + "Don't write a hex color anywhere except #b92a1b for the windowClose hover. No exceptions. No oklch() literals either; even those bypass the host theme.", + "Don't add var(--vscode-*, #fallback) fallback chains in theme.css. The runtime host plus the resolver are responsible for providing the variable; a fallback hides a real bug.", + "Don't add borders or shadows to pane headers or doors to 'make the hierarchy work.' The hierarchy is header-active-bg vs. header-inactive-bg.", + "Don't introduce a text-muted color inside an active or inactive pane header. Header-internal text inherits the header foreground; muting inside it breaks the focus signal.", + "Don't use rounded SaaS cards, gradient accents, gradient text, or glassmorphism. PRODUCT.md names these directly.", + "Don't use hacker-aesthetic green-on-black, terminal-cliché Matrix tints, or any color that signals 'this is a programmer tool.' The user's theme decides what color this tool is.", + "Don't animate layout properties (width, height, top, left, padding). Pane transitions use clip-path and opacity deliberately so layout measurements stay valid mid-animation.", + "Don't add an emoji, mascot, or illustration to chrome. PRODUCT.md is explicit.", + "Don't wrap things in containers. Most surfaces don't need one; the host's sidebar already is the container.", + "Don't introduce a new pass-through --mt-* token or a one-off color for tabs, badges, accents, or button hovers. If a new rendered surface truly needs a token that isn't in the hierarchy, update theme.css and design.tsx together, document the addition in docs/specs/theme.md, and update CONSUMED_VSCODE_KEYS in bundle-themes.mjs." + ] + } +} diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..b22e51f9 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,269 @@ +--- +name: MouseTerm +description: A mouse-friendly multitasking terminal that feels native inside VSCode. +colors: + app-bg: "var(--vscode-sideBar-background)" + app-fg: "var(--vscode-sideBar-foreground)" + surface-raised: "var(--vscode-editorWidget-background)" + foreground: "var(--vscode-editor-foreground)" + muted: "var(--vscode-descriptionForeground)" + border: "var(--vscode-panel-border)" + header-active-bg: "var(--vscode-list-activeSelectionBackground)" + header-active-fg: "var(--vscode-list-activeSelectionForeground)" + header-inactive-bg: "var(--vscode-list-inactiveSelectionBackground)" + header-inactive-fg: "var(--vscode-list-inactiveSelectionForeground)" + door-bg: "var(--color-door-bg)" + door-fg: "var(--color-door-fg)" + focus-ring: "var(--color-focus-ring)" + terminal-bg: "var(--vscode-terminal-background)" + terminal-fg: "var(--vscode-terminal-foreground)" + input-bg: "var(--vscode-input-background)" + input-border: "var(--vscode-input-border)" + error: "var(--vscode-terminal-ansiRed)" + success: "var(--vscode-terminal-ansiGreen)" + alarm: "var(--vscode-terminal-ansiYellow)" + window-close-hover: "#b92a1b" +typography: + body: + fontFamily: "var(--vscode-editor-font-family)" + fontSize: "0.75rem" + lineHeight: "1rem" + fontWeight: 500 + label: + fontFamily: "var(--vscode-editor-font-family)" + fontSize: "0.625rem" + lineHeight: "1rem" + fontWeight: 600 + letterSpacing: "0.08em" + shortcut: + fontFamily: "var(--vscode-editor-font-family)" + fontSize: "0.75rem" + fontWeight: 500 +rounded: + sm: "4px" + lg: "8px" +spacing: + xs: "2px" + sm: "6px" + md: "10px" + lg: "16px" +components: + door: + backgroundColor: "{colors.door-bg}" + textColor: "{colors.door-fg}" + rounded: "{rounded.lg}" + height: "24px" + padding: "0 10px" + typography: "{typography.body}" + header-action-button-icon: + rounded: "{rounded.sm}" + height: "20px" + width: "20px" + header-action-button-labeled: + rounded: "{rounded.sm}" + height: "20px" + padding: "0 6px" + typography: "{typography.body}" + popup-button-row: + backgroundColor: "{colors.surface-raised}" + textColor: "{colors.foreground}" + rounded: "{rounded.sm}" + typography: "{typography.body}" + chrome-button-window: + width: "44px" + height: "20px" + chrome-button-window-close: + width: "44px" + height: "20px" + todo-pill: + typography: "{typography.label}" + textColor: "{colors.foreground}" + kill-confirm-dialog: + backgroundColor: "{colors.surface-raised}" + textColor: "{colors.foreground}" + rounded: "{rounded.lg}" + padding: "16px 24px" +--- + +# Design System: MouseTerm + +## 1. Overview + +**Creative North Star: "The Native Tenant"** + +MouseTerm is a tenant in someone else's house. The house is VSCode. The user picked the furniture (their theme), the lighting (their mode), the typography (their editor font). MouseTerm moves in, multiplies what the user can do with their terminals, and leaves the decor alone. The interface should be indistinguishable from a built-in panel: not because it imitates VSCode, but because it inherits from VSCode. Every color, every font, every surface is a passthrough of the host's tokens. + +The system is intentionally minimal and bg-only. Chrome recedes; terminals are the content. Hierarchy is conveyed through background shifts between `header-active-bg` and `header-inactive-bg`, not through borders, shadows, or accent stripes. Status is conveyed through shape and position (a bell icon, a door's alert state) and through the active terminal palette's own ANSI red/green/yellow, not through a separate design-system palette. + +The system explicitly rejects: rounded SaaS cards, gradient accents, hacker-aesthetic green-on-black, "Slack-style" Electron chrome bloat, decorative animations, and any token that hardcodes a color. If a user installs a high-contrast theme, the chrome can look flatter than usual: that is accepted, not "fixed" with overrides. + +**Key Characteristics:** +- Host-theme-driven palette: every color is a `var(--vscode-*)` passthrough. +- Bg-only chrome: no decorative borders, no resting shadows, no accent stripes. +- Monospace everywhere: sans and mono resolve to the same VSCode editor font. +- Tight type scale: `text-xs` (10px) and `text-sm` (12px) override Tailwind defaults; almost everything sits on these two steps. +- Status through palette already in the room: alerts use the user's ANSI red / green / yellow, not a brand color. +- Motion: short, exponential ease-out for layout transitions; sparing spring-overshoot reserved for celebratory state-resolution. + +## 2. Colors + +The palette has no fixed values. Every semantic token resolves to a `--vscode-*` variable at runtime. Inside VSCode, those variables are injected by the host. Outside VSCode (standalone, website playground), `applyTheme()` materializes the same variable shape on `document.body` from a bundled MouseTerm theme. + +### Primary +This system has no "primary" accent in the brand sense. The closest analogue is the **focused-header pair**: +- **Header Active Background** (`var(--vscode-list-activeSelectionBackground)`): the bg of the focused pane's header tab. This is the only consistent visual cue for "this pane has focus." Used at full opacity for the header bg, and at `/25` opacity for the active terminal selection ring and copy-confirm flash background. +- **Header Active Foreground** (`var(--vscode-list-activeSelectionForeground)`): text on the focused header. Inherited by buttons inside the focused header. + +### Secondary +- **Header Inactive Background** (`var(--vscode-list-inactiveSelectionBackground)`): unfocused pane headers and the candidate bg for doors. The bg-bg delta between this and `header-active-bg` is the entire focus affordance. +- **Header Inactive Foreground** (`var(--vscode-list-inactiveSelectionForeground)`): text on unfocused headers. Inherits the nearest `sideBar.foreground`, not the active-selection white. + +### Tertiary +- **Door Background / Foreground** (`var(--color-door-bg)` / `var(--color-door-fg)`): runtime-chosen at body level by `computeDynamicPalette()`. Picks whichever of (inactive-header bg/fg) or (terminal bg/fg) has stronger OKLab perceptual separation from the app bg, so doors stay readable regardless of how close the user's chosen header and terminal palettes happen to be. +- **Focus Ring** (`var(--color-focus-ring)`): runtime-chosen. Prefers a chromatic `focusBorder`, then a chromatic active-header background, then the highest-contrast fallback. Used for the marching-ants command-mode ring and the terminal text-selection border. + +### Neutral +- **App Background** (`var(--vscode-sideBar-background)`): the chrome host. Baseboard, dockview gutters, gaps around panes. Reads as "the editor's sidebar," because it literally is. +- **App Foreground** (`var(--vscode-sideBar-foreground)`): default text on app chrome. +- **Surface Raised** (`var(--vscode-editorWidget-background)`): popovers, tooltips, dialogs, kill-confirm sheet, theme picker dropdown. Roughly one step above app-bg in the host's vocabulary. +- **Foreground** (`var(--vscode-editor-foreground)`): primary text in raised surfaces (popups, dialogs). +- **Muted** (`var(--vscode-descriptionForeground)`): secondary text, shortcut hints inside `[brackets]`, theme picker chip captions. +- **Border** (`var(--vscode-panel-border)`): hairline border on raised surfaces (popups, dialogs, theme picker). The only place borders carry weight. + +### Status +- **Terminal Background / Foreground** (`var(--vscode-terminal-background)` / `var(--vscode-terminal-foreground)`): the terminal content surface and xterm default text. Orthogonal to the chrome. +- **Error** (`var(--vscode-terminal-ansiRed)`): destructive actions and kill-confirm letter flash. +- **Success** (`var(--vscode-terminal-ansiGreen)`): TODO check, theme-store install confirm. +- **Alarm** (`var(--vscode-terminal-ansiYellow)` initial; runtime-rotated): bell-ringing alert tint. Per-surface OKLCH hue-rotation by `use-dynamic-palette.ts` from the bg the bell sits on, so the alert pops off any header. + +### Fixed Exception +- **Window Close Hover** (`#b92a1b`): the only literal color in the whole system. Native OS close-button hover on Windows/Linux chrome buttons; matches the platform convention across themes. + +### Named Rules +**The Host-Theme-Only Rule.** Never write a hex value or `oklch()` literal into `theme.css` or a component. Never use `var(..., fallback)` chains. Every color must resolve through `--vscode-*` or one of the body-published runtime picks (`--color-door-*`, `--color-focus-ring`, `--color-alarm-vs-*`). The one allowed exception is `#b92a1b` for native window-close hover. + +**The Bg-Only Chrome Rule.** Pane headers, doors, and the baseboard convey hierarchy through background shifts only. Do not add borders or shadows to "make the hierarchy work." If a high-contrast theme makes a header look flat against the app bg, that is the user's theme speaking; do not override. + +**The Active Header Doubles as Accent Rule.** Animated emphasis (copy-confirm flash, active-pane selection ring) tints with `header-active-bg/25`. The accent is not separate from the focus signal; they are the same color, used at different opacities. + +## 3. Typography + +**Display Font:** none (no display tier). +**Body Font:** `var(--vscode-editor-font-family)`. +**Label/Mono Font:** same as body. Sans and mono resolve to the same VSCode editor font. + +**Character:** monospace, the user's own editor face. The system has no opinion about Cascadia vs. SF Mono vs. JetBrains Mono vs. Fira Code; whatever is set in the editor is what MouseTerm uses, including ligature settings. This is the typographic equivalent of the host-theme rule. + +### Hierarchy +- **Body** (weight 500, `text-sm` = 0.75rem / 12px, line-height 1rem): pane headers, doors, popup contents, button labels. The single most-used step. +- **Label** (weight 600, `text-xs` = 0.625rem / 10px, line-height 1rem, `tracking-[0.08em]`): TODO pills, kill-confirm hint, shortcut prompts. The wider tracking is non-negotiable at this size; without it the pill characters smear together. +- **Shortcut** (weight 500, `text-sm`, muted color): keybinding text inside `[brackets]`. Always rendered with the `Shortcut` component or `renderShortcuts()`, never inline. + +The Tailwind defaults for `text-xs` (12px) and `text-sm` (14px) are overridden in `theme.css` to 10px and 12px respectively. The override is intentional: the chrome needs to recede, and the default Tailwind sizes are too loud against terminal output. + +### Named Rules +**The Two-Step Rule.** Almost everything sits on `text-xs` or `text-sm`. If a new surface wants `text-base` or larger, the surface is probably wrong. Make it smaller or rethink the affordance. + +**The Bracket-Shortcut Rule.** Keybindings always render as `[k]` in muted color via `` or `renderShortcuts(...)`. Never put `Ctrl+K` or `⌘K` in chrome text. The bracket convention is the entire visual hint system for "this is a key." + +## 4. Elevation + +Flat by default. Pane headers, doors, the baseboard, and terminal panes carry zero shadow at rest. Hierarchy is delegated to background shifts (active vs. inactive header) and to position (doors sit on the baseboard, the baseboard sits below panes). + +Shadows appear only on **raised surfaces that float above content**: popovers, tooltips, dialogs. They are ambient, not structural; they say "I am temporary and on top," not "I have weight." + +### Shadow Vocabulary +- **Popover** (`box-shadow: var(--tw-shadow-md)`): tooltips (`PopupButtonRow`), selection popup, illegal-rename warning, terminal-pane header tooltips. +- **Dialog** (`box-shadow: var(--tw-shadow-lg)`): kill-confirm sheet, TODO alert dialog. +- **Modal** (`box-shadow: var(--tw-shadow-2xl)`): theme picker dropdown (when expanded), theme debugger, theme store dialog. +- **Inset hairline** (`box-shadow: inset 0 0 0 1px var(--color-focus-ring)` / `var(--color-border)`): mobile UI segmented controls. Used instead of `border` when the surface needs a 1px stroke that does not shift layout on state change. + +### Named Rules +**The Flat-At-Rest Rule.** Surfaces in the resting layout (panes, doors, baseboard, terminal content) carry no shadow. Shadow appears only when a surface enters the air (popover, tooltip, dialog, modal). + +**The Inset-Over-Border Rule.** When a surface needs a 1px stroke that may toggle on state change (active vs. inactive), prefer `shadow-[inset_0_0_0_1px_…]` over `border`. The shadow does not affect layout; the border does. + +## 5. Components + +### Doors +Doors are the pane-header indicators on the baseboard. The most signature component in the system. +- **Shape:** top corners only — `rounded-t-lg` (8px). The bottom is square so the door visually anchors to the baseboard. The pane body owns the bottom corners (`rounded-b-lg`); together they form one continuous rounded rectangle when expanded. +- **Surface:** `bg-door-bg` + `text-door-fg`. These resolve at runtime via `computeDynamicPalette()` and may match either the inactive-header palette or the terminal palette, whichever has stronger separation from `app-bg`. +- **Dimensions:** `h-6` (24px), `min-w-[68px]`, `max-w-[220px]`, horizontal padding `px-2.5` (10px), `gap-2` between title and badges. +- **Type:** `text-sm font-medium font-mono`. +- **Content:** truncated title; optional TODO pill (`text-xs font-semibold tracking-[0.08em]`, success-tinted when flourishing); optional bell icon (`size={11}`, `weight="fill"`), `text-alarm-vs-door` when ringing. +- **Hover/Focus:** no decorative hover. The whole door is a button; the focus state is conveyed by the parent pane's selection ring, not by a per-door treatment. + +### Buttons + +#### Header Action Button +The icon-and-tooltip button used inside pane headers (kill, alert toggle, todo, etc.). +- **Shape:** `rounded` (4px) when icon-only, also `rounded` for labeled variants. +- **Color:** `text-inherit` — inherits the header's foreground, so it tints with the active/inactive header palette. +- **Hover:** `hover:bg-current/10` — a 10%-opacity wash of the current text color. Theme-agnostic, works light or dark. +- **Tooltip:** rendered through a portal as a `PopupButtonRow` 8px below the button, with `text-sm` primary line and an optional muted detail line. Keybindings inside the tooltip auto-render as `[bracketed]` shortcuts. + +#### Chrome Button (window controls) +The Windows/Linux native-style window control row in the standalone app bar. +- **Variants:** `icon` (h-5 min-w-5, hover bg-current/10), `labeled` (h-5 min-w-5 px-1.5 text-xs), `window` (w-11, hover bg-current/10), `windowClose` (w-11, hover bg `#b92a1b` text-white). +- **The exception:** `windowClose` is the only place a literal hex color is permitted, because the platform convention is a hard red regardless of theme. + +### Cards / Containers + +The system uses **raised surfaces**, not "cards." There are no nested cards. There is no resting card grid. +- **Raised surface** (`PopupButtonRow`, tooltips, popups): `bg-surface-raised`, `border border-border`, `rounded` (4px), `shadow-md`, `font-mono text-sm`. +- **Dialog** (`KillConfirm`, `TodoAlertDialog`): `bg-surface-raised`, `border border-border`, `rounded-lg` (8px), `shadow-lg`, generous padding (`px-6 py-4` for kill-confirm). +- **Modal** (`ThemePicker` dropdown, `ThemeDebugger`, `ThemeStoreDialog`): `bg-surface-raised`, `border`, `rounded`, `shadow-2xl`, fixed-position with viewport-clamped sizing. + +### Inputs +- Used by `ThemePicker`. Style: `bg-input-bg`, `border border-input-border`, `rounded`, `font-mono`, `text-sm`. +- **Focus:** native browser focus outline; this is acceptable because the entire input lives inside a raised surface that already has `shadow-2xl` and a border. + +### Navigation + +The system has no traditional top-nav. Two surfaces play navigational roles: +- **Baseboard** (bottom of the app): horizontal strip of doors representing minimized panes plus chrome action buttons. Doors are the primary navigation affordance to a minimized terminal. Button style: `h-5 rounded px-1.5 text-sm font-medium font-mono text-muted` with `hover:bg-surface-raised hover:text-foreground transition-colors`. +- **Pane Header (TerminalPaneHeader)**: the tab-replacing strip at the top of each pane. Tab-bar styling is stripped from dockview entirely (`--dv-tabs-and-actions-container-*` overrides); the React header IS the tab. + +### Signature Components + +#### TODO Pill +A tiny inline label that appears in pane headers and inside doors when a terminal has a TODO state. +- `text-xs font-semibold tracking-[0.08em]` — the tracking is mandatory at this size. +- Grid-stacked `` and `` so width stays stable when the dismiss flourish runs. +- **Flourish (500ms):** letters fade 0–30%, check springs in 0–40% with `cubic-bezier(0.34, 1.56, 0.64, 1)` (overshoot 1.15x, settle to 1.0x at 55%), whole pill dissolves 55–100%. Reduced-motion replaces the entire sequence with opacity:0 at zero duration. + +#### Pane Spawn / Kill Choreography +The most distinctive motion in the system. Implemented as `clip-path` reveals, not transforms, so `getBoundingClientRect` stays accurate during the animation (the selection overlay measures real bounds). +- **Spawn:** 440ms `cubic-bezier(0.22, 1, 0.36, 1)` clip-path reveal from left / top / top-left, depending on which side of the layout the new pane appeared on. +- **Kill (edge/middle):** 440ms `pane-fade-out`. The neighbor's spawn-grow carries the directional cue; the dying pane just fades. +- **Kill (last-pane):** 440ms `pane-fade-and-shrink-to-br`, paired with `ring-shrink-to-br` so the focus ring stays glued to the pane as it disappears. +- **Reduced-motion:** all of the above are nulled. + +#### Marching Ants (Command Mode) +The selection ring around the focused pane in command mode is an SVG with `stroke-dasharray` and an infinite `marching-ants` keyframe that increments `stroke-dashoffset` by `var(--march-offset)`. Color: `var(--color-focus-ring)`. This is the system's only ongoing animation; it is meant to be the visual signature of "you are now in command mode." + +## 6. Do's and Don'ts + +### Do: +- **Do** route every color through a `--vscode-*` variable or a body-published runtime pick (`--color-door-*`, `--color-focus-ring`, `--color-alarm-vs-*`). The frontmatter is the contract. +- **Do** keep chrome on `text-xs` (10px) and `text-sm` (12px). The Tailwind defaults are overridden for a reason; do not write `text-base` into chrome to "make it readable." +- **Do** use `hover:bg-current/10` for neutral hover feedback inside theme-tinted chrome. It is the one hover treatment that always works. +- **Do** convey active-vs-inactive pane state through the `header-active-bg` / `header-inactive-bg` background swap. That is the entire focus affordance. +- **Do** render keybindings as `[k]` via `` / `renderShortcuts`. Always muted, always bracketed. +- **Do** gate every keyframe animation behind `@media (prefers-reduced-motion: reduce)`. The pattern is in `theme.css` already; reuse it. +- **Do** use `shadow-[inset_0_0_0_1px_var(--color-…)]` instead of `border` when a stroke may toggle on state change. Border shifts layout; inset shadow does not. +- **Do** treat doors as the navigation primitive. Doors own `rounded-t-lg`; terminal bodies own `rounded-b-lg`; together they form one rectangle. +- **Do** use the spring-overshoot curve `cubic-bezier(0.34, 1.56, 0.64, 1)` only for state-resolution moments (TODO check, kill confirm, copy flash), and keep durations short (220–500ms). + +### Don't: +- **Don't** write a hex color anywhere except `#b92a1b` for the windowClose hover. No exceptions. No `oklch()` literals either; even those bypass the host theme. +- **Don't** add `var(--vscode-*, #fallback)` fallback chains in `theme.css`. The runtime host plus the resolver are responsible for providing the variable; a fallback hides a real bug. +- **Don't** add borders or shadows to pane headers or doors to "make the hierarchy work." The hierarchy is `header-active-bg` vs. `header-inactive-bg`. If a high-contrast theme makes that look flat, accept it. +- **Don't** introduce a `text-muted` color inside an active or inactive pane header. Header-internal text inherits the header foreground; muting inside it breaks the focus signal. +- **Don't** use rounded SaaS cards, gradient accents, gradient text, or glassmorphism. PRODUCT.md names these directly: "Generic SaaS (rounded cards, gradients, startup illustrations)," "Electron bloat (Slack — heavy, slow-feeling, too much chrome)." +- **Don't** use hacker-aesthetic green-on-black, terminal-cliché Matrix tints, or any color that signals "this is a programmer tool." The user's theme decides what color this tool is. +- **Don't** animate layout properties (`width`, `height`, `top`, `left`, `padding`). Pane transitions use `clip-path` and `opacity` deliberately so layout measurements stay valid mid-animation. +- **Don't** add an emoji, mascot, or illustration to chrome. PRODUCT.md is explicit: "Overly playful (too many animations, emojis, mascots)." +- **Don't** wrap things in containers. Most surfaces don't need one; the host's sidebar already is the container. +- **Don't** introduce a new pass-through `--mt-*` token or a one-off color for tabs, badges, accents, or button hovers. If a new rendered surface truly needs a token that isn't in the hierarchy above, update `theme.css` and `design.tsx` together, document the addition in `docs/specs/theme.md`, and update `CONSUMED_VSCODE_KEYS` in `bundle-themes.mjs`. diff --git a/.impeccable.md b/PRODUCT.md similarity index 99% rename from .impeccable.md rename to PRODUCT.md index 624b8d0c..ad907a8c 100644 --- a/.impeccable.md +++ b/PRODUCT.md @@ -1,3 +1,7 @@ +## Register + +product + ## Design Context ### Users diff --git a/docs/specs/layout.md b/docs/specs/layout.md index f9c0697f..8e75670a 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -18,7 +18,7 @@ Transitioning between Pane and Door does not alter the Session in any way. Minim There are two areas: - **Content** — tiling layout containing Panes, powered by dockview -- **Baseboard** — always-visible bottom strip containing Doors and shortcut hints +- **Baseboard** — bottom strip containing Doors and shortcut hints. It is visible in the main shell; tightly constrained embedders may suppress it with `Wall showBaseboard={false}` when they do not expose door/minimize workflows. The user can navigate between all elements using the mouse, or by entering `command` mode and using the keyboard. @@ -32,7 +32,7 @@ Wall │ │ │ ├── TerminalPanel → TerminalPane → xterm.js │ │ │ └── TerminalPaneHeader (tab component, drag handle) │ │ └── WorkspaceSelectionOverlay (fixed positioned, pointer-events: none) -│ ├── Baseboard (always-visible bottom strip, shortcut hints when empty) +│ ├── Baseboard (bottom strip, shortcut hints when empty; optional for constrained embedders) │ │ └── Door components (one per minimized session) │ └── KillConfirmOverlay (conditional) ``` @@ -104,7 +104,9 @@ The header adapts to available width via ResizeObserver in three tiers: ## Baseboard -Below the content area is the baseboard (`h-7`, 28px). It is always visible and has no top divider. The dockview area ends 2px above it, leaving a narrow theme-colored gap that keeps rounded pane corners distinct from the baseboard. Its horizontal padding matches the Dockview wrapper's 6px inset, so doors align with the panes above. When empty, it shows keyboard shortcut hints when there are no doors and the container is wider than 350px (currently: `LCmd → RCmd to enter command mode`). +Below the content area is the baseboard (`h-7`, 28px). It is visible by default and has no top divider. The dockview area ends 2px above it, leaving a narrow theme-colored gap that keeps rounded pane corners distinct from the baseboard. Its horizontal padding matches the Dockview wrapper's 6px inset, so doors align with the panes above. When empty, it shows keyboard shortcut hints when there are no doors and the container is wider than 350px (currently: `LCmd → RCmd to enter command mode`). + +`Wall` accepts `showBaseboard={false}` for constrained embedders such as the website mobile Tether prototype, where a separate bottom navigation owns the area below the terminal and door workflows are outside the prototype scope. The main app shell keeps the default `showBaseboard=true`. When a session is minimized, it becomes a **door** on the baseboard. The door displays the same derived terminal label as the pane header, a TODO badge (if set), and an alert bell icon with activity dot. It uses the bottom edge of the window as its bottom border, with left, top, and right borders using the shared terminal top radius from `lib/src/components/design.tsx` — resembling a mouse hole and matching pane rounding. Door dimensions: `min-w-[68px] max-w-[220px] h-6`. diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md new file mode 100644 index 00000000..5b92e0eb --- /dev/null +++ b/docs/specs/mobile-ui.md @@ -0,0 +1,335 @@ +# Mobile Terminal Website Prototype Spec + +## 1. Overview + +This document specifies the `/tether` mobile terminal prototype. + +The prototype tests one core idea: + +```text +Stable terminal viewport + explicit touch mode + explicit keyboard mode. +``` + +The app should feel like a lightweight mobile terminal playground. It does not +need remote sessions, SSH, user accounts, or production infrastructure. + +The website `/tether` prototype exposes a small floating theme switcher above +the terminal. It uses the shared MouseTerm `ThemePicker`. + +`/tether` uses the same fake playground terminal stack as `/playground`: +`PlaygroundShellRegistry` attaches a `TutorialShell` to every spawned pane, the +same fake commands dispatch to browser-side runners, and the first pane simply +auto-runs `ascii-splash` as its initial command. + +## 2. Prototype Goals + +Primary goals: + +* Keep the terminal viewport stable when the native phone keyboard opens or closes. +* Let the user explicitly choose what terminal touches mean. +* Let the user explicitly choose what appears in the stable keyboard reserve area. +* Test normal mobile text entry using the native phone keyboard. +* Provide enough terminal behavior to evaluate typing, Enter, Backspace, arrows, Escape, Tab, and app interruption. +* Keep the implementation small and easy to iterate on. + +Non-goals: + +* Remote shell support. +* SSH support. +* WebSocket transport. +* User accounts. +* Session persistence. +* Command history storage. +* A real draft/scratchpad workflow. +* Advanced gestures. +* Production security hardening. +* Full accessibility implementation. + +## 3. Core Layout + +The mobile UI is split into fixed and flexible regions: + +```text +┌─────────────────────────┐ +│ Pane title │ fixed/small +├─────────────────────────┤ +│ Pane content │ flexible terminal area +├─────────────────────────┤ +│ Touch mode selector │ labeled, always visible +├─────────────────────────┤ +│ Keyboard mode selector │ labeled, always visible +├─────────────────────────┤ +│ Keyboard reserve area │ stable height +│ │ +│ Shows app keyboard UI │ when OS keyboard hidden +│ Occupied by OS keyboard │ when OS keyboard visible +└─────────────────────────┘ +``` + +The pane title and pane content come from the embedded `Wall` terminal pane. The +mobile wrapper owns the two selectors and the fixed-height keyboard reserve. + +The root height must not be recalculated from `window.visualViewport` on every +keyboard resize. The reserve area is intentionally stable so the terminal region +does not bounce while the OS keyboard animates. + +## 4. Touch Mode Selector + +The touch selector controls what happens when the user touches the pane content +area. It is always visible between the terminal content and the keyboard mode +selector. + +The selector must be self-labeling. It should use a compact left-side `Touch` +label plus segmented buttons that include both an icon and a short mode label. +Icon-only touch controls are too hard to discover in this prototype. + +Touch modes: + +| Mode | Button label | Icon | Availability | Behavior | +| --- | --- | --- | --- | --- | +| Gestures | `Gestures` | `HandPointingIcon` | Always available | Touch drags generate arrow keys. Drag left sends left, drag right sends right, drag up sends up, and drag down sends down. | +| Text selection | `Select` | `CursorTextIcon` | Always available | Touches are reserved for terminal text selection and copy/paste. If the TUI is capturing mouse events, MouseTerm activates mouse override for the active pane. | +| Cursor | `Cursor` | `CursorClickIcon` | Only when the active TUI is capturing mouse events | Touches are passed through as terminal mouse/cursor input. | + +Default touch mode is **Gestures**. + +If Cursor mode is active and the active pane stops capturing mouse events, the +selector must fall back to Gestures. + +## 5. Keyboard Mode Selector + +The keyboard mode selector controls what appears in the keyboard reserve area. +It is always visible and has four items: + +```text +Recent | Type | Draft | Keys +``` + +The selector must be self-labeling. It should use a compact left-side `Input` +label plus segmented text buttons. The label describes the reserve area's +purpose without adding a longer instruction line. + +Keyboard modes: + +| Mode | Reserve area content | +| --- | --- | +| Recent | The entire reserve area displays `Recent - WIP`. | +| Type | The reserve area focuses the hidden terminal input. Every typed key is echoed into the terminal as it happens. | +| Draft | The entire reserve area displays `Draft - WIP`. | +| Keys | The entire reserve area displays terminal key buttons. | + +Default keyboard mode is **Type**. + +Switching to Type should focus the hidden input and open the native keyboard +where browser policy allows. Switching away from Type should blur the hidden +input so the app keyboard UI is visible again. + +Tapping the **Type** selector must focus the hidden input synchronously during +the tap/click handler. Do not defer this focus to `requestAnimationFrame` or a +timer, because mobile browsers may then treat it as no longer user-initiated and +refuse to open the native keyboard. + +## 6. Keys Mode + +Keys mode displays exactly these buttons: + +```text +Esc Tab Space Enter +← ↓ ↑ → +``` + +Mappings: + +| Button | Sequence | +| --- | --- | +| Esc | `\x1B` | +| Tab | `\x09` | +| Space | ` ` | +| Enter | `\r` | +| ← | `\x1B[D` | +| ↓ | `\x1B[B` | +| ↑ | `\x1B[A` | +| → | `\x1B[C` | + +Tapping a key sends exactly one action. Long-press repeat is not required for v0. + +## 7. Type Mode Input + +Use a hidden or visually minimal input configured for terminal-style typing: + +```html + +``` + +Required behavior: + +* Normal characters are sent to the active terminal immediately. +* Enter sends terminal Enter. +* Backspace works. +* Physical `Ctrl+C` sends `\x03`. +* Autocorrect and autocapitalization are disabled where possible. +* Input supports mobile keyboard behavior and IME composition. +* The app does not depend only on `keydown` for text input. + +## 8. Terminal Playground Behavior + +A fake shell is acceptable for v0. + +Minimum useful behavior: + +* Echo typed characters. +* Maintain a command line buffer. +* Enter submits the current command. +* Backspace edits the current command. +* Arrow keys produce visible behavior. +* Escape and Tab produce visible behavior. +* When a fake full-screen app such as `ascii-splash`, `splash`, `changelog`, or + `tut` is running, `Ctrl+C` sends `\x03` to that app; if the app exits, the + terminal returns to the fake shell prompt instead of restarting the app. +* New panes created from the wall get the same fake shell behavior and prompt as + regular `/playground` panes. + +Example commands: + +```text +help +clear +echo hello +ascii-splash +changelog +tut +``` + +The shell only needs enough behavior to test the mobile controls. + +## 9. Keyboard Reserve + +The keyboard reserve area has a stable height. It should not be recomputed from +`visualViewport` while the native keyboard animates. + +When the OS keyboard is hidden, the reserve area shows the selected app keyboard +UI (`Recent - WIP`, Type focus target, `Draft - WIP`, or Keys buttons). + +When the OS keyboard is visible, the OS keyboard may cover or occupy that same +physical area. This is preferred over resizing the whole app around the keyboard. + +## 10. Touch Interactions + +Required interactions: + +* Tap keyboard mode selector items. +* Tap touch mode selector items. +* Tap Type reserve area to focus typing. +* Type through the native keyboard. +* Tap key buttons in Keys mode. +* Drag in Gestures mode to send arrow keys. +* Use Text selection mode for terminal selection and copy/paste. +* Use Cursor mode for terminal mouse/cursor input when a TUI requests mouse reporting. + +Pane-content touches must never open the native keyboard. The pane content area +may focus the terminal internally for key routing or mouse handling, but the +mobile wrapper must configure text inputs created by the terminal surface as +non-keyboard targets (`inputmode="none"`, readonly, not tab-reachable) and +immediately blur them when the touch starts there. Since `Wall` may defer xterm +focus to `requestAnimationFrame`, the wrapper must also repeat that blur shortly +after the touch. The only mobile UI surfaces that should open the native +keyboard are the Type selector and the Type reserve area. + +Not required for v0: + +* Long-press key repeat. +* Multi-touch gestures. +* Trackpad mode. +* A full command history UI. +* A real draft editor. + +## 11. Copy And Paste + +Keep copy and paste minimal. + +Prototype behavior: + +* Text selection mode should allow the existing terminal selection and copy/paste flows to work. +* Let users paste through the native browser/OS paste flow where possible. +* No custom mobile clipboard manager is required. +* No multi-line paste review is required. + +## 12. Recommended v0 Scope + +Build exactly this: + +* One terminal playground screen. +* Floating theme switcher using the shared MouseTerm theme picker. +* Touch mode selector: + +```text +Touch Gestures | Select | Cursor +``` + +* Keyboard mode selector: + +```text +Input Recent | Type | Draft | Keys +``` + +* Stable keyboard reserve area. +* Recent reserve content: `Recent - WIP`. +* Draft reserve content: `Draft - WIP`. +* Type mode native mobile keyboard input. +* Keys buttons: + +```text +Esc Tab Space Enter +← ↓ ↑ → +``` + +* Simple local playground terminal behavior. + +## 13. Prototype Success Criteria + +The prototype should answer these questions: + +1. Does the terminal viewport feel stable when the mobile keyboard opens and closes? +2. Is the touch mode selector understandable and reachable? +3. Are gesture arrows usable enough for command history and cursor movement? +4. Is text selection discoverable and reliable on mobile? +5. Is Cursor mode useful when a TUI captures mouse events? +6. Does native keyboard Type mode feel acceptable for terminal text entry? +7. Does the stable keyboard reserve feel better than resizing the whole UI? +8. Is the UI too cramped in portrait orientation? + +## 14. Future Work + +Potential later additions: + +* Real recent commands. +* Draft scratchpad. +* Dual-pane copy/paste. +* Pinned snippets. +* Ctrl+C, Ctrl+D, and Ctrl+Z app-key buttons. +* Alt and modifier behavior. +* Home, End, PgUp, PgDn. +* Long-press key repeat. +* Remote backend PTY. +* SSH sessions. +* User accounts. +* Session persistence. +* Multi-session support. +* Production security model. + +## 15. Product Principle + +The v0 prototype should stay focused: + +```text +Touch modes make pane touches explicit. +Keyboard modes make the reserve area explicit. +Everything else waits. +``` diff --git a/docs/specs/theme.md b/docs/specs/theme.md index 5eec0ac8..b8dccca6 100644 --- a/docs/specs/theme.md +++ b/docs/specs/theme.md @@ -152,10 +152,13 @@ terminal colors. It captures the current DOM-visible theme state and shows: - dynamic door/focus-ring picks from the same `pickDoorPair()` and `pickFocusRing()` helpers used by Wall's `computeDynamicPalette()`. -Standalone and playground expose the debugger as `Debug current theme` in the -`ThemePicker` menu. VSCode opens it through the `mouseterm.debugTheme` command -and the `mouseterm:openThemeDebugger` extension-to-webview message. The -debugger's copied report is a shareable text dump of the same snapshot. +Standalone, playground, and the website `/tether` prototype expose the debugger +as `Debug current theme` in the `ThemePicker` menu. `/tether` uses the same +picker in the desktop page header and as a floating control above the mobile +terminal prototype, both with the Kimbie Dark default theme fallback. VSCode +opens it through the `mouseterm.debugTheme` command and the +`mouseterm:openThemeDebugger` extension-to-webview message. The debugger's +copied report is a shareable text dump of the same snapshot. ## Maintainer checklist diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx new file mode 100644 index 00000000..a04f1c66 --- /dev/null +++ b/lib/src/components/MobileTerminalUi.tsx @@ -0,0 +1,570 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, + type ComponentType, + type KeyboardEvent, + type PointerEvent, + type ReactNode, +} from 'react'; +import { + CursorClickIcon, + CursorTextIcon, + HandPointingIcon, +} from '@phosphor-icons/react'; +import { clsx } from 'clsx'; + +export type MobileTerminalKeyboardMode = 'recent' | 'type' | 'draft' | 'keys'; +export type MobileTerminalSection = MobileTerminalKeyboardMode; +export type MobileTerminalTouchMode = 'gestures' | 'selection' | 'cursor'; + +export const MOBILE_TERMINAL_KEY_SEQUENCES = { + ctrlC: '\x03', + esc: '\x1b', + tab: '\x09', + space: ' ', + enter: '\r', + backspace: '\x7f', + up: '\x1b[A', + down: '\x1b[B', + right: '\x1b[C', + left: '\x1b[D', +} as const; + +interface TerminalKey { + id: keyof typeof MOBILE_TERMINAL_KEY_SEQUENCES; + label: string; + title: string; +} + +const TERMINAL_KEYS: TerminalKey[] = [ + { id: 'esc', label: 'Esc', title: 'Escape' }, + { id: 'tab', label: 'Tab', title: 'Tab' }, + { id: 'space', label: 'Space', title: 'Space' }, + { id: 'enter', label: 'Enter', title: 'Enter' }, + { id: 'left', label: '\u2190', title: 'Left arrow' }, + { id: 'down', label: '\u2193', title: 'Down arrow' }, + { id: 'up', label: '\u2191', title: 'Up arrow' }, + { id: 'right', label: '\u2192', title: 'Right arrow' }, +]; + +const KEYBOARD_MODES: { id: MobileTerminalKeyboardMode; label: string }[] = [ + { id: 'recent', label: 'Recent' }, + { id: 'type', label: 'Type' }, + { id: 'draft', label: 'Draft' }, + { id: 'keys', label: 'Keys' }, +]; + +const TOUCH_MODES: Array<{ + id: MobileTerminalTouchMode; + label: string; + shortLabel: string; + title: string; + Icon: ComponentType<{ size?: number; weight?: 'regular' | 'bold' | 'duotone' | 'fill' }>; +}> = [ + { id: 'gestures', label: 'Gestures', shortLabel: 'Gestures', title: 'Gestures: drags send arrow keys', Icon: HandPointingIcon }, + { id: 'selection', label: 'Text selection', shortLabel: 'Select', title: 'Text selection: touches select terminal text', Icon: CursorTextIcon }, + { id: 'cursor', label: 'Cursor', shortLabel: 'Cursor', title: 'Cursor: touches send terminal mouse events', Icon: CursorClickIcon }, +]; + +export interface MobileTerminalUiProps { + terminal: ReactNode; + activeSection?: MobileTerminalKeyboardMode; + defaultSection?: MobileTerminalKeyboardMode; + onSectionChange?: (section: MobileTerminalKeyboardMode) => void; + activeKeyboardMode?: MobileTerminalKeyboardMode; + defaultKeyboardMode?: MobileTerminalKeyboardMode; + onKeyboardModeChange?: (mode: MobileTerminalKeyboardMode) => void; + activeTouchMode?: MobileTerminalTouchMode; + defaultTouchMode?: MobileTerminalTouchMode; + onTouchModeChange?: (mode: MobileTerminalTouchMode) => void; + cursorTouchAvailable?: boolean; + onSendInput?: (data: string) => void; + onFocusInput?: () => void; + interactive?: boolean; + fillViewport?: boolean; + className?: string; + terminalClassName?: string; + style?: CSSProperties; +} + +function keyDownSequence(event: KeyboardEvent): string | null { + if (event.ctrlKey && event.key.toLowerCase() === 'c') { + return MOBILE_TERMINAL_KEY_SEQUENCES.ctrlC; + } + + switch (event.key) { + case 'Enter': + return MOBILE_TERMINAL_KEY_SEQUENCES.enter; + case 'Backspace': + return MOBILE_TERMINAL_KEY_SEQUENCES.backspace; + case 'Escape': + return MOBILE_TERMINAL_KEY_SEQUENCES.esc; + case 'Tab': + return MOBILE_TERMINAL_KEY_SEQUENCES.tab; + case 'ArrowUp': + return MOBILE_TERMINAL_KEY_SEQUENCES.up; + case 'ArrowDown': + return MOBILE_TERMINAL_KEY_SEQUENCES.down; + case 'ArrowRight': + return MOBILE_TERMINAL_KEY_SEQUENCES.right; + case 'ArrowLeft': + return MOBILE_TERMINAL_KEY_SEQUENCES.left; + default: + return null; + } +} + +function KeyButton({ + item, + disabled, + onPress, +}: { + item: TerminalKey; + disabled: boolean; + onPress: (id: keyof typeof MOBILE_TERMINAL_KEY_SEQUENCES) => void; +}) { + return ( + + ); +} + +function KeyboardModeButton({ + id, + label, + selected, + disabled, + onSelect, +}: { + id: MobileTerminalKeyboardMode; + label: string; + selected: boolean; + disabled: boolean; + onSelect: (mode: MobileTerminalKeyboardMode) => void; +}) { + return ( + + ); +} + +function SelectorLabel({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function TouchModeSelector({ + mode, + cursorAvailable, + disabled, + onSelect, +}: { + mode: MobileTerminalTouchMode; + cursorAvailable: boolean; + disabled: boolean; + onSelect: (mode: MobileTerminalTouchMode) => void; +}) { + return ( +
+ Touch +
+ {TOUCH_MODES.map((item) => { + const selected = item.id === mode; + const itemDisabled = disabled || (item.id === 'cursor' && !cursorAvailable); + const Icon = item.Icon; + return ( + + ); + })} +
+
+ ); +} + +function KeyboardModeSelector({ + mode, + disabled, + onSelect, +}: { + mode: MobileTerminalKeyboardMode; + disabled: boolean; + onSelect: (mode: MobileTerminalKeyboardMode) => void; +}) { + return ( +
+ Input + +
+ ); +} + +function WorkInProgressPane({ label }: { label: 'Recent' | 'Draft' }) { + return ( +
+ {label} - WIP +
+ ); +} + +export function MobileTerminalUi({ + terminal, + activeSection, + defaultSection = 'type', + onSectionChange, + activeKeyboardMode, + defaultKeyboardMode, + onKeyboardModeChange, + activeTouchMode, + defaultTouchMode = 'gestures', + onTouchModeChange, + cursorTouchAvailable = false, + onSendInput, + onFocusInput, + interactive = true, + fillViewport = false, + className, + terminalClassName, + style, +}: MobileTerminalUiProps) { + const resolvedDefaultKeyboardMode = defaultKeyboardMode ?? defaultSection; + const [internalKeyboardMode, setInternalKeyboardMode] = useState(resolvedDefaultKeyboardMode); + const [internalTouchMode, setInternalTouchMode] = useState(defaultTouchMode); + const keyboardMode = activeKeyboardMode ?? activeSection ?? internalKeyboardMode; + const touchMode = activeTouchMode ?? internalTouchMode; + const terminalHostRef = useRef(null); + const inputRef = useRef(null); + const composingRef = useRef(false); + const gestureStartRef = useRef<{ pointerId: number; x: number; y: number } | null>(null); + const [inputValue, setInputValue] = useState(''); + + const sendInput = useCallback((data: string) => { + if (!interactive || data.length === 0) return; + onSendInput?.(data); + }, [interactive, onSendInput]); + + const focusInput = useCallback(() => { + if (!interactive) return; + onFocusInput?.(); + inputRef.current?.focus({ preventScroll: true }); + }, [interactive, onFocusInput]); + + const blurInput = useCallback(() => { + inputRef.current?.blur(); + }, []); + + const configurePaneTextInputs = useCallback(() => { + const host = terminalHostRef.current; + if (!host) return; + for (const input of host.querySelectorAll('input, textarea')) { + if (input.inputMode !== 'none') input.inputMode = 'none'; + if (input.autocomplete !== 'off') input.autocomplete = 'off'; + if (!input.readOnly) input.readOnly = true; + if (input.tabIndex !== -1) input.tabIndex = -1; + } + }, []); + + const blurPaneTextInputs = useCallback(() => { + if (typeof document === 'undefined') return; + const blurActivePaneInput = () => { + configurePaneTextInputs(); + inputRef.current?.blur(); + const active = document.activeElement; + if (!(active instanceof HTMLElement)) return; + if (!terminalHostRef.current?.contains(active)) return; + if ( + active instanceof HTMLInputElement + || active instanceof HTMLTextAreaElement + || active.isContentEditable + ) { + active.blur(); + } + }; + blurActivePaneInput(); + window.setTimeout(blurActivePaneInput, 0); + window.setTimeout(blurActivePaneInput, 50); + window.setTimeout(blurActivePaneInput, 200); + window.requestAnimationFrame(blurActivePaneInput); + }, [configurePaneTextInputs]); + + const setKeyboardMode = useCallback((nextMode: MobileTerminalKeyboardMode) => { + if (activeKeyboardMode === undefined && activeSection === undefined) { + setInternalKeyboardMode(nextMode); + } + onKeyboardModeChange?.(nextMode); + onSectionChange?.(nextMode); + if (nextMode === 'type') { + focusInput(); + } else { + blurInput(); + } + }, [activeKeyboardMode, activeSection, blurInput, focusInput, onKeyboardModeChange, onSectionChange]); + + const setTouchMode = useCallback((nextMode: MobileTerminalTouchMode) => { + if (nextMode === 'cursor' && !cursorTouchAvailable) return; + if (activeTouchMode === undefined) setInternalTouchMode(nextMode); + onTouchModeChange?.(nextMode); + }, [activeTouchMode, cursorTouchAvailable, onTouchModeChange]); + + const flushInputValue = useCallback((value: string) => { + if (value) sendInput(value); + setInputValue(''); + }, [sendInput]); + + useEffect(() => { + if (keyboardMode !== 'type' || !interactive) return; + const frame = window.requestAnimationFrame(focusInput); + const delayedFocus = window.setTimeout(focusInput, 120); + const settledFocus = window.setTimeout(focusInput, 500); + return () => { + window.cancelAnimationFrame(frame); + window.clearTimeout(delayedFocus); + window.clearTimeout(settledFocus); + }; + }, [focusInput, interactive, keyboardMode]); + + useEffect(() => { + if (touchMode === 'cursor' && !cursorTouchAvailable) { + setTouchMode('gestures'); + } + }, [cursorTouchAvailable, setTouchMode, touchMode]); + + useEffect(() => { + const host = terminalHostRef.current; + if (!host) return; + configurePaneTextInputs(); + const observer = new MutationObserver(configurePaneTextInputs); + observer.observe(host, { + childList: true, + subtree: true, + }); + return () => observer.disconnect(); + }, [configurePaneTextInputs, terminal]); + + const handlePanePointerDownCapture = useCallback((event: PointerEvent) => { + blurPaneTextInputs(); + if (!interactive || touchMode !== 'gestures') return; + if (event.pointerType === 'mouse') return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + gestureStartRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; + }, [interactive, touchMode]); + + const handlePanePointerUpCapture = useCallback((event: PointerEvent) => { + const start = gestureStartRef.current; + if (!start || start.pointerId !== event.pointerId) return; + gestureStartRef.current = null; + event.preventDefault(); + event.stopPropagation(); + + const dx = event.clientX - start.x; + const dy = event.clientY - start.y; + if (Math.max(Math.abs(dx), Math.abs(dy)) < 24) return; + if (Math.abs(dx) > Math.abs(dy)) { + sendInput(dx < 0 ? MOBILE_TERMINAL_KEY_SEQUENCES.left : MOBILE_TERMINAL_KEY_SEQUENCES.right); + } else { + sendInput(dy < 0 ? MOBILE_TERMINAL_KEY_SEQUENCES.up : MOBILE_TERMINAL_KEY_SEQUENCES.down); + } + }, [sendInput]); + + const handlePaneFocusStartCapture = useCallback(() => { + blurPaneTextInputs(); + }, [blurPaneTextInputs]); + + const handlePanePointerCancelCapture = useCallback((event: PointerEvent) => { + if (gestureStartRef.current?.pointerId === event.pointerId) { + gestureStartRef.current = null; + } + }, []); + + return ( +
+
+
{terminal}
+
+ + + + + +
+ {keyboardMode === 'recent' ? : null} + {keyboardMode === 'draft' ? : null} + {keyboardMode === 'type' ? ( + + ) : null} + {keyboardMode === 'keys' ? ( +
+
+ {TERMINAL_KEYS.slice(0, 4).map((item) => ( + sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[id])} + /> + ))} +
+
+ {TERMINAL_KEYS.slice(4).map((item) => ( + sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[id])} + /> + ))} +
+
+ ) : null} +
+ +