Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1086-face-room-layout-clip.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Dismissing an Enter-chat error and typing in the room composer no longer shifts the window sideways. The page and the app frame cannot scroll horizontally, and the error toast stays inside the viewport ([#1086](https://github.com/ThinkFlowLab/FlyBot/issues/1086)).
61 changes: 58 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -14820,14 +14820,14 @@ var FEEDBACK_CSS = `
.bt-fb-text{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
.bt-fb-code{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
.bt-event-error{color:#e02135}
.bt-toast{position:fixed;left:50%;bottom:28px;z-index:1000;display:flex;align-items:center;gap:8px;max-width:min(560px,calc(100vw - 32px));padding:8px 8px 8px 14px;border-radius:12px;background:var(--bt-card,#fff);color:var(--bt-ink,#141414);border:1px solid var(--bt-line-2,rgba(0,0,0,.15));box-shadow:0 8px 28px rgba(0,0,0,.14);font-size:13px;line-height:18px;transform:translateX(-50%);animation:bt-toast-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
.bt-toast[data-leaving]{opacity:0;transform:translate(-50%,6px) scale(.98);filter:blur(3px);transition:opacity .18s ease-in,transform .18s ease-in,filter .18s ease-in}
.bt-toast{position:fixed;left:0;right:0;bottom:28px;z-index:1000;display:flex;align-items:center;gap:8px;width:fit-content;max-width:min(560px,calc(100vw - 32px));margin-inline:auto;padding:8px 8px 8px 14px;border-radius:12px;background:var(--bt-card,#fff);color:var(--bt-ink,#141414);border:1px solid var(--bt-line-2,rgba(0,0,0,.15));box-shadow:0 8px 28px rgba(0,0,0,.14);font-size:13px;line-height:18px;animation:bt-toast-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
.bt-toast[data-leaving]{opacity:0;transform:translateY(6px) scale(.98);filter:blur(3px);transition:opacity .18s ease-in,transform .18s ease-in,filter .18s ease-in}
.bt-toast-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}
.bt-toast-text::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:#e02135;margin:0 8px 1px 0}
.bt-toast-link{flex:none;border:0;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;padding:4px 6px;border-radius:6px}
.bt-toast-link:hover{background:var(--bt-hover);color:var(--bt-ink)}
.bt-toast-close{flex:none}
@keyframes bt-toast-in{from{transform:translate(-50%,16px) scale(.94)}}
@keyframes bt-toast-in{from{transform:translateY(16px) scale(.94)}}
@media (prefers-reduced-motion:reduce){.bt-toast{animation:none}.bt-toast[data-leaving]{transition:none}}
`;

Expand Down Expand Up @@ -17500,7 +17500,61 @@ var PANE_MIN_WIDTH_CSS = `
.bt-panel :is(.bt-member,.bt-members,.bt-bcard-row,.bt-drawer-model,.bt-usage-row){min-width:0}
.bt-panel :is(.bt-muted,.bt-note){white-space:normal}
[class*="_centerCol"]{min-width:0}
html,body,#root{overflow-x:clip}
[class*="_frame"]>[class*="_centerCol"]{overflow-x:clip}
[class*="_frame"]:has(> [class*="_centerCol"]){overflow-x:clip}
`;
var COMPOSER = "[data-composer-input], [data-composer-card]";
function clipScroll(el) {
if (el && el.scrollLeft > 0) el.scrollLeft = 0;
}
function appFrameOf(doc) {
if (typeof doc.querySelector !== "function") return null;
const col = doc.querySelector('[class*="_centerCol"]');
const frame = col?.parentElement;
if (!frame) return null;
const name = frame.className;
const text = typeof name === "string" ? name : name?.baseVal ?? "";
return text.includes("_frame") ? frame : null;
}
function levelShell(doc, from) {
const page = doc.scrollingElement || doc.documentElement;
clipScroll(page);
clipScroll(doc.documentElement);
clipScroll(doc.body);
clipScroll(appFrameOf(doc));
for (let at = from?.parentElement || null; at; at = at.parentElement) clipScroll(at);
}
function composerOf(node) {
let el = node;
if (el && el.nodeType !== 1) el = el.parentElement;
if (!el || typeof el.closest !== "function") return null;
return el.closest(COMPOSER);
}
function watchShellLevel(doc) {
const later = (from) => {
const reset = () => levelShell(doc, from);
reset();
if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset));
};
const onFocus = (event) => later(composerOf(event.target) || void 0);
const onInput = (event) => {
const composer = composerOf(event.target);
if (composer) later(composer);
};
const onScroll = (event) => {
const target = event.target;
if (target === doc || target === doc.documentElement || target === doc.body) levelShell(doc);
};
doc.addEventListener("focusin", onFocus);
doc.addEventListener("input", onInput);
doc.addEventListener("scroll", onScroll, true);
return () => {
doc.removeEventListener("focusin", onFocus);
doc.removeEventListener("input", onInput);
doc.removeEventListener("scroll", onScroll, true);
};
}

// src/client/friend-lines.js
function friendLineNews(bots, known, isOpen, selfId) {
Expand Down Expand Up @@ -18731,6 +18785,7 @@ function apply(ctx) {
installCompiledStyles(USER_MESSAGE_CSS, "user-messages"),
installCompiledStyles(MOBILE_CSS, "mobile"),
installCompiledStyles(PANE_MIN_WIDTH_CSS, "panes"),
typeof document !== "undefined" ? watchShellLevel(document) : null,
browsers ? installCompiledStyles(BROWSER_CSS, "browser") : null,
installTheme(),
shadow("sidebar.workspaces", { priority: -10, inject: face }, TeamSidebar),
Expand Down
6 changes: 3 additions & 3 deletions core/src/client/feedback.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,13 @@ export const FEEDBACK_CSS = `
.bt-fb-text{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
.bt-fb-code{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
.bt-event-error{color:#e02135}
.bt-toast{position:fixed;left:50%;bottom:28px;z-index:1000;display:flex;align-items:center;gap:8px;max-width:min(560px,calc(100vw - 32px));padding:8px 8px 8px 14px;border-radius:12px;background:var(--bt-card,#fff);color:var(--bt-ink,#141414);border:1px solid var(--bt-line-2,rgba(0,0,0,.15));box-shadow:0 8px 28px rgba(0,0,0,.14);font-size:13px;line-height:18px;transform:translateX(-50%);animation:bt-toast-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
.bt-toast[data-leaving]{opacity:0;transform:translate(-50%,6px) scale(.98);filter:blur(3px);transition:opacity .18s ease-in,transform .18s ease-in,filter .18s ease-in}
.bt-toast{position:fixed;left:0;right:0;bottom:28px;z-index:1000;display:flex;align-items:center;gap:8px;width:fit-content;max-width:min(560px,calc(100vw - 32px));margin-inline:auto;padding:8px 8px 8px 14px;border-radius:12px;background:var(--bt-card,#fff);color:var(--bt-ink,#141414);border:1px solid var(--bt-line-2,rgba(0,0,0,.15));box-shadow:0 8px 28px rgba(0,0,0,.14);font-size:13px;line-height:18px;animation:bt-toast-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
.bt-toast[data-leaving]{opacity:0;transform:translateY(6px) scale(.98);filter:blur(3px);transition:opacity .18s ease-in,transform .18s ease-in,filter .18s ease-in}
.bt-toast-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}
.bt-toast-text::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:#e02135;margin:0 8px 1px 0}
.bt-toast-link{flex:none;border:0;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;padding:4px 6px;border-radius:6px}
.bt-toast-link:hover{background:var(--bt-hover);color:var(--bt-ink)}
.bt-toast-close{flex:none}
@keyframes bt-toast-in{from{transform:translate(-50%,16px) scale(.94)}}
@keyframes bt-toast-in{from{transform:translateY(16px) scale(.94)}}
@media (prefers-reduced-motion:reduce){.bt-toast{animation:none}.bt-toast[data-leaving]{transition:none}}
`
3 changes: 2 additions & 1 deletion core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { SCHEDULE_CSS } from './schedules.js'
import { INBOX_CSS } from './inbox.js'
import { KANBAN_CSS } from './kanban.js'
import { MOBILE_CSS } from './mobile.js'
import { PANE_MIN_WIDTH_CSS } from './panes.js'
import { PANE_MIN_WIDTH_CSS, watchShellLevel } from './panes.js'
import { STORAGE, migrateStorage } from './storage.js'
import { friendLineNews } from './friend-lines.js'
import { groupsFor, readMode, surfaceGroups, writeMode } from './surface.js'
Expand Down Expand Up @@ -864,6 +864,7 @@ export function apply(ctx) {
installCompiledStyles(USER_MESSAGE_CSS, 'user-messages'),
installCompiledStyles(MOBILE_CSS, 'mobile'),
installCompiledStyles(PANE_MIN_WIDTH_CSS, 'panes'),
typeof document !== 'undefined' ? watchShellLevel(document) : null,
browsers ? installCompiledStyles(BROWSER_CSS, 'browser') : null,
installTheme(),
shadow('sidebar.workspaces', { priority: -10, inject: face }, TeamSidebar),
Expand Down
74 changes: 74 additions & 0 deletions core/src/client/panes.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
// overflows — the panel ends up clipped and a dead strip is left between the panes. Every flexing
// pane gets min-width:0 so it can shrink, and the long values wrap. Installed after the shell
// styles, so these declarations win at equal specificity.
//
// #1086: a centered error toast (left:50% + translateX(-50%), or DSH's own) and the focus
// restore after its dialog grow the scroll width, then typing in the composer scrolls an
// ancestor sideways. overflow:hidden still honors scrollLeft and scrollIntoView; clip does
// not, and it does not turn the other axis into a scrollport. The frame is matched only when
// it directly contains the center column — other "_frame" classes (image, zoom) stay alone.
// The child rule outranks DSH's .centerCol { overflow:hidden } even when that sheet loads later.
export const PANE_MIN_WIDTH_CSS = `
.bt-panel{min-width:0}
.bt-panel-view{min-width:0}
Expand All @@ -12,4 +19,71 @@ export const PANE_MIN_WIDTH_CSS = `
.bt-panel :is(.bt-member,.bt-members,.bt-bcard-row,.bt-drawer-model,.bt-usage-row){min-width:0}
.bt-panel :is(.bt-muted,.bt-note){white-space:normal}
[class*="_centerCol"]{min-width:0}
html,body,#root{overflow-x:clip}
[class*="_frame"]>[class*="_centerCol"]{overflow-x:clip}
[class*="_frame"]:has(> [class*="_centerCol"]){overflow-x:clip}
`

const COMPOSER = '[data-composer-input], [data-composer-card]'

function clipScroll(el) {
if (el && el.scrollLeft > 0) el.scrollLeft = 0
}

// The app frame is the only "_frame" whose direct child is the center column.
function appFrameOf(doc) {
if (typeof doc.querySelector !== 'function') return null
const col = doc.querySelector('[class*="_centerCol"]')
const frame = col?.parentElement
if (!frame) return null
const name = frame.className
const text = typeof name === 'string' ? name : (name?.baseVal ?? '')
return text.includes('_frame') ? frame : null
}

// Put a leftover sideways offset back on the page, the app frame, and — when `from` is the
// composer — every ancestor of it. The composer itself is left alone (a long line may scroll
// inside the field) and so is a transcript scroller, which is not an ancestor (#274, #1086).
export function levelShell(doc, from) {
const page = doc.scrollingElement || doc.documentElement
clipScroll(page)
clipScroll(doc.documentElement)
clipScroll(doc.body)
clipScroll(appFrameOf(doc))
for (let at = from?.parentElement || null; at; at = at.parentElement) clipScroll(at)
}

function composerOf(node) {
let el = node
if (el && el.nodeType !== 1) el = el.parentElement
if (!el || typeof el.closest !== 'function') return null
return el.closest(COMPOSER)
}

// Focus, typing, and a document scroll can each land the offset after the first reset
// (the dialog's focus restore does not use preventScroll). Twice across frames, as
// keepSidebarStill does for the sidebar gear.
export function watchShellLevel(doc) {
const later = (from) => {
const reset = () => levelShell(doc, from)
reset()
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => requestAnimationFrame(reset))
}
const onFocus = (event) => later(composerOf(event.target) || undefined)
const onInput = (event) => {
const composer = composerOf(event.target)
if (composer) later(composer)
}
const onScroll = (event) => {
const target = event.target
if (target === doc || target === doc.documentElement || target === doc.body) levelShell(doc)
}
doc.addEventListener('focusin', onFocus)
doc.addEventListener('input', onInput)
doc.addEventListener('scroll', onScroll, true)
return () => {
doc.removeEventListener('focusin', onFocus)
doc.removeEventListener('input', onInput)
doc.removeEventListener('scroll', onScroll, true)
}
}
149 changes: 149 additions & 0 deletions core/test/shell-level.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { PANE_MIN_WIDTH_CSS, levelShell, watchShellLevel } from '../src/client/panes.js'

// #1086: dismissing an Enter-chat error and typing in the composer used to leave the
// window scrolled sideways (the #274 class, at the shell). overflow:hidden still
// scrolls; overflow-x:clip does not, and a leftover scrollLeft is put back.

const read = (rel) => readFileSync(new URL(rel, import.meta.url), 'utf8')

function box(scrollLeft, parent, extra = {}) {
return { scrollLeft, parentElement: parent, ...extra }
}

function page() {
const root = box(0, null)
const body = box(0, root)
const frame = box(0, body, { className: 'x_frame' })
const col = box(0, frame, { className: 'x_centerCol' })
const card = box(0, col)
const input = box(0, card, {
nodeType: 1,
closest(sel) {
const text = String(sel)
return text.includes('data-composer-input') || text.includes('data-composer-card') ? input : null
},
})
const code = box(0, col, { nodeType: 1, closest() { return null } })
const listeners = new Map()
return {
scrollingElement: root,
documentElement: root,
body,
frame,
input,
code,
querySelector(sel) {
return sel === '[class*="_centerCol"]' ? col : null
},
addEventListener(type, fn) {
const list = listeners.get(type) ?? []
list.push(fn)
listeners.set(type, list)
},
removeEventListener(type, fn) {
listeners.set(type, (listeners.get(type) ?? []).filter(item => item !== fn))
},
fire(type, target) {
for (const fn of listeners.get(type) ?? []) fn({ target })
},
}
}

test('#1086: the document and the app frame cannot scroll sideways', () => {
assert.match(PANE_MIN_WIDTH_CSS, /html,body,#root\{overflow-x:clip\}/)
assert.match(PANE_MIN_WIDTH_CSS, /\[class\*="_frame"\]>\[class\*="_centerCol"\]\{overflow-x:clip\}/)
assert.match(PANE_MIN_WIDTH_CSS, /\[class\*="_frame"\]:has\(> \[class\*="_centerCol"\]\)\{overflow-x:clip\}/)
const index = read('../src/client/index.js')
const panesAt = index.indexOf('installCompiledStyles(PANE_MIN_WIDTH_CSS')
const watchAt = index.indexOf('watchShellLevel(document)')
assert.ok(panesAt > 0 && watchAt > panesAt, 'watchShellLevel is installed with the pane rules')
const bundle = read('../client.js')
assert.ok(bundle.includes('html,body,#root{overflow-x:clip}'), 'client.js carries the document clip')
})

test('#1086: a sideways offset on the page and the frame is cleared, a transcript scroller is not', () => {
const doc = page()
doc.scrollingElement.scrollLeft = 40
doc.body.scrollLeft = 16
doc.frame.scrollLeft = 9
doc.code.scrollLeft = 30
levelShell(doc)
assert.equal(doc.scrollingElement.scrollLeft, 0)
assert.equal(doc.body.scrollLeft, 0)
assert.equal(doc.frame.scrollLeft, 0)
assert.equal(doc.code.scrollLeft, 30)
doc.input.scrollLeft = 3
doc.input.parentElement.scrollLeft = 4
doc.code.scrollLeft = 30
levelShell(doc, doc.input)
assert.equal(doc.input.scrollLeft, 3)
assert.equal(doc.input.parentElement.scrollLeft, 0)
assert.equal(doc.code.scrollLeft, 30)
})

test('#1086: typing in the composer puts the shell back; a code block keeps its own scroll', () => {
const doc = page()
const stop = watchShellLevel(doc)
doc.scrollingElement.scrollLeft = 22
doc.fire('scroll', doc)
assert.equal(doc.scrollingElement.scrollLeft, 0)
doc.code.scrollLeft = 18
doc.fire('scroll', doc.code)
assert.equal(doc.code.scrollLeft, 18)
doc.frame.scrollLeft = 11
doc.body.scrollLeft = 6
doc.fire('input', doc.input)
assert.equal(doc.frame.scrollLeft, 0)
assert.equal(doc.body.scrollLeft, 0)
doc.scrollingElement.scrollLeft = 7
doc.code.scrollLeft = 14
doc.fire('focusin', doc.code)
assert.equal(doc.scrollingElement.scrollLeft, 0)
assert.equal(doc.code.scrollLeft, 14)
stop()
doc.scrollingElement.scrollLeft = 5
doc.fire('scroll', doc)
assert.equal(doc.scrollingElement.scrollLeft, 5)
})

test('#1086: a scrollLeft that lands after the composer focuses is put back', () => {
const doc = page()
const queued = []
const previous = globalThis.requestAnimationFrame
globalThis.requestAnimationFrame = (fn) => {
queued.push(fn)
return queued.length
}
try {
const stop = watchShellLevel(doc)
doc.fire('focusin', doc.input)
doc.body.scrollLeft = 24
assert.ok(queued.length >= 1)
queued[0]()
doc.body.scrollLeft = 24
queued.at(-1)()
assert.equal(doc.body.scrollLeft, 0)
stop()
} finally {
if (previous === undefined) delete globalThis.requestAnimationFrame
else globalThis.requestAnimationFrame = previous
}
})

test('#1086: the error toast is centered without a box that hangs past the viewport', () => {
const feedback = read('../src/client/feedback.js')
const toast = feedback.match(/\.bt-toast\{([^}]*)\}/)?.[1] ?? ''
assert.match(toast, /left:0/)
assert.match(toast, /right:0/)
assert.match(toast, /margin-inline:auto/)
assert.match(toast, /width:fit-content/)
assert.doesNotMatch(toast, /left:50%/)
assert.doesNotMatch(toast, /translateX\(-50%\)/)
const leaving = feedback.match(/\.bt-toast\[data-leaving\]\{([^}]*)\}/)?.[1] ?? ''
assert.match(leaving, /translateY\(6px\)/)
assert.doesNotMatch(leaving, /-50%/)
assert.match(feedback, /@keyframes bt-toast-in\{from\{transform:translateY\(16px\) scale\(\.94\)\}\}/)
})
Loading
Loading