From 9f952efe370eb2e3e409ff5a7143bb27aedae6bf Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 30 Oct 2026 10:00:36 +0800 Subject: [PATCH] Fix the Project board overlay: add a scrim and cover the shell The overlay carried 'bt-overlay', a class no stylesheet defines, so the board rendered as a short dialog in the page flow with the bot tree and workflows legible under its rows. It now follows the Board's modal: a bt-scrim sibling that closes on click, and a fixed inset:0 layer holding kanban's bt-mem dialog size. The ds-bot patches are regenerated because core/ is ds-bot/ + patches/ds-bot/. Docs-not-needed: no architecture change. --- changelog.d/1750-project-board-scrim.fixed.md | 1 + core/client.js | 51 ++++++++++++------ core/src/client/project-board.js | 46 ++++++++++------ core/test/ui-clickthrough.test.js | 24 +++++++++ patches/ds-bot/0008-client-bundle.patch | 53 +++++++++++++------ .../ds-bot/files/src/client/project-board.js | 46 ++++++++++------ .../ds-bot/files/test/ui-clickthrough.test.js | 24 +++++++++ 7 files changed, 182 insertions(+), 63 deletions(-) create mode 100644 changelog.d/1750-project-board-scrim.fixed.md diff --git a/changelog.d/1750-project-board-scrim.fixed.md b/changelog.d/1750-project-board-scrim.fixed.md new file mode 100644 index 000000000..64f0d3e72 --- /dev/null +++ b/changelog.d/1750-project-board-scrim.fixed.md @@ -0,0 +1 @@ +The Project board now opens as a real modal: a dimming scrim sits in front of the board and covers the shell, so the bot tree and workflows no longer read as extra board rows, and a click beside the board closes it ([#1750](https://github.com/ThinkFlowLab/FlyBot/issues/1750)). diff --git a/core/client.js b/core/client.js index af79faf7c..241a6ebb2 100644 --- a/core/client.js +++ b/core/client.js @@ -19624,6 +19624,12 @@ function watchShellLevel(doc) { // src/client/project-board.js var import_react46 = require("react"); var PROJECT_BOARD_CSS = ` +/* #1750: the board was a short dialog in the page flow — no stylesheet defines 'bt-overlay', so the + bot tree and workflows stayed legible under its rows. It is a modal now, on the same tier as the + Board and Settings layers, with kanban's own 'bt-scrim' sibling and 'bt-mem' surface. */ +.bt-project-overlay{position:fixed;inset:0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.bt-mem.bt-project-dialog{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))} +.bt-project-body{flex:1 1 auto;min-height:0;overflow:auto} .bt-project-board{padding:16px 18px} .bt-project-group{margin:0 0 14px} .bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} @@ -19780,21 +19786,36 @@ function ProjectBoardOverlay({ actions } = {}) { if (state === null) return null; return (0, import_react46.createElement)( "div", - { className: "bt-overlay bt-project-overlay", role: "dialog", "aria-label": t("Project board") }, - (0, import_react46.createElement)("button", { type: "button", className: "bt-project-close", onClick: close }, t("Close")), - state.project === null ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No Project here.")) : (0, import_react46.createElement)(ProjectBoardPane, { - project: state.project, - rows: state.rows, - canManage: state.canManage, - tasks: state.tasks, - // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and - // lands on that Bot or group. Nothing here re-decides which board that is. - onOpen: (board) => { - if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`; - actions?.openBoard?.(); - setState(null); - } - }) + { className: "bt-project-overlay" }, + (0, import_react46.createElement)("div", { className: "bt-scrim", onClick: close }), + (0, import_react46.createElement)( + "div", + { className: "bt-mem bt-project-dialog", role: "dialog", "aria-modal": true, "aria-label": t("Project board") }, + (0, import_react46.createElement)( + "div", + { className: "bt-mem-head" }, + (0, import_react46.createElement)("h2", {}, t("Project board")), + (0, import_react46.createElement)("span", { className: "bt-mem-head-gap" }), + (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-project-close", autoFocus: true, onClick: close }, t("Close")) + ), + (0, import_react46.createElement)( + "div", + { className: "bt-project-body" }, + state.project === null ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No Project here.")) : (0, import_react46.createElement)(ProjectBoardPane, { + project: state.project, + rows: state.rows, + canManage: state.canManage, + tasks: state.tasks, + // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and + // lands on that Bot or group. Nothing here re-decides which board that is. + onOpen: (board) => { + if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`; + actions?.openBoard?.(); + setState(null); + } + }) + ) + ) ); } diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 0187f8711..3304c5702 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -6,6 +6,12 @@ import { call } from './api.js' import { t } from './i18n.js' export const PROJECT_BOARD_CSS = ` +/* #1750: the board was a short dialog in the page flow — no stylesheet defines 'bt-overlay', so the + bot tree and workflows stayed legible under its rows. It is a modal now, on the same tier as the + Board and Settings layers, with kanban's own 'bt-scrim' sibling and 'bt-mem' surface. */ +.bt-project-overlay{position:fixed;inset:0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.bt-mem.bt-project-dialog{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))} +.bt-project-body{flex:1 1 auto;min-height:0;overflow:auto} .bt-project-board{padding:16px 18px} .bt-project-group{margin:0 0 14px} .bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} @@ -164,20 +170,28 @@ export function ProjectBoardOverlay({ actions } = {}) { return () => { live = false } }, [hash]) if (state === null) return null - return h('div', { className: 'bt-overlay bt-project-overlay', role: 'dialog', 'aria-label': t('Project board') }, - h('button', { type: 'button', className: 'bt-project-close', onClick: close }, t('Close')), - state.project === null - ? h('div', { className: 'bt-project-empty' }, t('No Project here.')) - // A row opens the board its task lives on, carried by `#task=`: the Tasks board reads - // that hash and lands on the Bot or group the task belongs to. - : h(ProjectBoardPane, { - project: state.project, rows: state.rows, canManage: state.canManage, tasks: state.tasks, - // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and - // lands on that Bot or group. Nothing here re-decides which board that is. - onOpen: (board) => { - if (typeof window !== 'undefined') window.location.hash = `#task=${board.kind}:${board.id}` - actions?.openBoard?.() - setState(null) - }, - })) + // #1750: the scrim is a sibling in front of the dialog, exactly as the Board draws it, so a click + // beside the board dismisses it and the shell behind reads as a backdrop rather than more rows. + return h('div', { className: 'bt-project-overlay' }, + h('div', { className: 'bt-scrim', onClick: close }), + h('div', { className: 'bt-mem bt-project-dialog', role: 'dialog', 'aria-modal': true, 'aria-label': t('Project board') }, + h('div', { className: 'bt-mem-head' }, + h('h2', {}, t('Project board')), + h('span', { className: 'bt-mem-head-gap' }), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-project-close', autoFocus: true, onClick: close }, t('Close'))), + h('div', { className: 'bt-project-body' }, + state.project === null + ? h('div', { className: 'bt-project-empty' }, t('No Project here.')) + // A row opens the board its task lives on, carried by `#task=`: the Tasks board reads + // that hash and lands on the Bot or group the task belongs to. + : h(ProjectBoardPane, { + project: state.project, rows: state.rows, canManage: state.canManage, tasks: state.tasks, + // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and + // lands on that Bot or group. Nothing here re-decides which board that is. + onOpen: (board) => { + if (typeof window !== 'undefined') window.location.hash = `#task=${board.kind}:${board.id}` + actions?.openBoard?.() + setState(null) + }, + })))) } diff --git a/core/test/ui-clickthrough.test.js b/core/test/ui-clickthrough.test.js index dc164c0b9..cc5b7ae3c 100644 --- a/core/test/ui-clickthrough.test.js +++ b/core/test/ui-clickthrough.test.js @@ -199,3 +199,27 @@ test('#1347: the built bundle carries these rules, so a page reload shows them', assert.ok(bundle.includes('bt-chat-strip-name'), 'client.js carries the blank-chat strip') assert.ok(bundle.includes('Conversation copied as Markdown'), 'client.js carries the copy confirmation') }) + +test('#1750: the Project board overlay covers the shell behind its scrim', () => { + const css = read('project-board.js') + // The overlay carried `bt-overlay`, a class no stylesheet defines: the board rendered as a short + // dialog in the page flow, with the bot tree and workflows legible under its rows and nothing + // dimming them. A modal covers the whole viewport, so the shell cannot be read as more board rows. + const overlay = rule(css, '.bt-project-overlay') + assert.match(overlay, /position:fixed/, 'the layer is taken out of the page flow') + assert.match(overlay, /inset:0/, 'and covers the viewport, not just its own content height') + assert.match(overlay, /align-items:center/, 'the dialog sits centred in that layer') + assert.match(overlay, /justify-content:center/) + // Every other modal in the client pairs a `bt-scrim` sibling with its dialog and dismisses on a + // click; the Project board was the one overlay with no scrim at all. + assert.match(css, /className: 'bt-scrim', onClick: close/, 'the scrim renders beside the dialog and closes the overlay') + // Reuse, not a second visual language: the dialog is kanban's own `bt-mem` surface at kanban's size. + const dialog = rule(css, '.bt-mem.bt-project-dialog') + assert.match(dialog, /width:min\(1080px,calc\(100vw - 32px\)\)/, 'the same dialog width as the Board') + assert.match(dialog, /height:min\(760px,calc\(100vh - 48px\)\)/, 'and the same height, so board rows are not mixed with the shell') + + // The app runs client.js, so the rule only reaches the page once it is in the bundle. + const bundle = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + assert.match(rule(bundle, '.bt-project-overlay'), /position:fixed;inset:0/, 'client.js carries the covering layer') + assert.ok(bundle.includes('bt-mem bt-project-dialog'), 'and the dialog it centres') +}) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 62853161f..9c379991c 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -20056,7 +20056,7 @@ diff --git a/client.js b/client.js } function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) { const views = /* @__PURE__ */ new Map(); -@@ -13052,11 +19530,295 @@ +@@ -13052,11 +19530,316 @@ }; } @@ -20154,6 +20154,12 @@ diff --git a/client.js b/client.js +// src/client/project-board.js +var import_react46 = require("react"); +var PROJECT_BOARD_CSS = ` ++/* #1750: the board was a short dialog in the page flow — no stylesheet defines 'bt-overlay', so the ++ bot tree and workflows stayed legible under its rows. It is a modal now, on the same tier as the ++ Board and Settings layers, with kanban's own 'bt-scrim' sibling and 'bt-mem' surface. */ ++.bt-project-overlay{position:fixed;inset:0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} ++.bt-mem.bt-project-dialog{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))} ++.bt-project-body{flex:1 1 auto;min-height:0;overflow:auto} +.bt-project-board{padding:16px 18px} +.bt-project-group{margin:0 0 14px} +.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} @@ -20310,21 +20316,36 @@ diff --git a/client.js b/client.js + if (state === null) return null; + return (0, import_react46.createElement)( + "div", -+ { className: "bt-overlay bt-project-overlay", role: "dialog", "aria-label": t("Project board") }, -+ (0, import_react46.createElement)("button", { type: "button", className: "bt-project-close", onClick: close }, t("Close")), -+ state.project === null ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No Project here.")) : (0, import_react46.createElement)(ProjectBoardPane, { -+ project: state.project, -+ rows: state.rows, -+ canManage: state.canManage, -+ tasks: state.tasks, -+ // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and -+ // lands on that Bot or group. Nothing here re-decides which board that is. -+ onOpen: (board) => { -+ if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`; -+ actions?.openBoard?.(); -+ setState(null); -+ } -+ }) ++ { className: "bt-project-overlay" }, ++ (0, import_react46.createElement)("div", { className: "bt-scrim", onClick: close }), ++ (0, import_react46.createElement)( ++ "div", ++ { className: "bt-mem bt-project-dialog", role: "dialog", "aria-modal": true, "aria-label": t("Project board") }, ++ (0, import_react46.createElement)( ++ "div", ++ { className: "bt-mem-head" }, ++ (0, import_react46.createElement)("h2", {}, t("Project board")), ++ (0, import_react46.createElement)("span", { className: "bt-mem-head-gap" }), ++ (0, import_react46.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-project-close", autoFocus: true, onClick: close }, t("Close")) ++ ), ++ (0, import_react46.createElement)( ++ "div", ++ { className: "bt-project-body" }, ++ state.project === null ? (0, import_react46.createElement)("div", { className: "bt-project-empty" }, t("No Project here.")) : (0, import_react46.createElement)(ProjectBoardPane, { ++ project: state.project, ++ rows: state.rows, ++ canManage: state.canManage, ++ tasks: state.tasks, ++ // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and ++ // lands on that Bot or group. Nothing here re-decides which board that is. ++ onOpen: (board) => { ++ if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`; ++ actions?.openBoard?.(); ++ setState(null); ++ } ++ }) ++ ) ++ ) + ); +} + diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 0187f8711..3304c5702 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -6,6 +6,12 @@ import { call } from './api.js' import { t } from './i18n.js' export const PROJECT_BOARD_CSS = ` +/* #1750: the board was a short dialog in the page flow — no stylesheet defines 'bt-overlay', so the + bot tree and workflows stayed legible under its rows. It is a modal now, on the same tier as the + Board and Settings layers, with kanban's own 'bt-scrim' sibling and 'bt-mem' surface. */ +.bt-project-overlay{position:fixed;inset:0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +.bt-mem.bt-project-dialog{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))} +.bt-project-body{flex:1 1 auto;min-height:0;overflow:auto} .bt-project-board{padding:16px 18px} .bt-project-group{margin:0 0 14px} .bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7} @@ -164,20 +170,28 @@ export function ProjectBoardOverlay({ actions } = {}) { return () => { live = false } }, [hash]) if (state === null) return null - return h('div', { className: 'bt-overlay bt-project-overlay', role: 'dialog', 'aria-label': t('Project board') }, - h('button', { type: 'button', className: 'bt-project-close', onClick: close }, t('Close')), - state.project === null - ? h('div', { className: 'bt-project-empty' }, t('No Project here.')) - // A row opens the board its task lives on, carried by `#task=`: the Tasks board reads - // that hash and lands on the Bot or group the task belongs to. - : h(ProjectBoardPane, { - project: state.project, rows: state.rows, canManage: state.canManage, tasks: state.tasks, - // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and - // lands on that Bot or group. Nothing here re-decides which board that is. - onOpen: (board) => { - if (typeof window !== 'undefined') window.location.hash = `#task=${board.kind}:${board.id}` - actions?.openBoard?.() - setState(null) - }, - })) + // #1750: the scrim is a sibling in front of the dialog, exactly as the Board draws it, so a click + // beside the board dismisses it and the shell behind reads as a backdrop rather than more rows. + return h('div', { className: 'bt-project-overlay' }, + h('div', { className: 'bt-scrim', onClick: close }), + h('div', { className: 'bt-mem bt-project-dialog', role: 'dialog', 'aria-modal': true, 'aria-label': t('Project board') }, + h('div', { className: 'bt-mem-head' }, + h('h2', {}, t('Project board')), + h('span', { className: 'bt-mem-head-gap' }), + h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-project-close', autoFocus: true, onClick: close }, t('Close'))), + h('div', { className: 'bt-project-body' }, + state.project === null + ? h('div', { className: 'bt-project-empty' }, t('No Project here.')) + // A row opens the board its task lives on, carried by `#task=`: the Tasks board reads + // that hash and lands on the Bot or group the task belongs to. + : h(ProjectBoardPane, { + project: state.project, rows: state.rows, canManage: state.canManage, tasks: state.tasks, + // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and + // lands on that Bot or group. Nothing here re-decides which board that is. + onOpen: (board) => { + if (typeof window !== 'undefined') window.location.hash = `#task=${board.kind}:${board.id}` + actions?.openBoard?.() + setState(null) + }, + })))) } diff --git a/patches/ds-bot/files/test/ui-clickthrough.test.js b/patches/ds-bot/files/test/ui-clickthrough.test.js index dc164c0b9..cc5b7ae3c 100644 --- a/patches/ds-bot/files/test/ui-clickthrough.test.js +++ b/patches/ds-bot/files/test/ui-clickthrough.test.js @@ -199,3 +199,27 @@ test('#1347: the built bundle carries these rules, so a page reload shows them', assert.ok(bundle.includes('bt-chat-strip-name'), 'client.js carries the blank-chat strip') assert.ok(bundle.includes('Conversation copied as Markdown'), 'client.js carries the copy confirmation') }) + +test('#1750: the Project board overlay covers the shell behind its scrim', () => { + const css = read('project-board.js') + // The overlay carried `bt-overlay`, a class no stylesheet defines: the board rendered as a short + // dialog in the page flow, with the bot tree and workflows legible under its rows and nothing + // dimming them. A modal covers the whole viewport, so the shell cannot be read as more board rows. + const overlay = rule(css, '.bt-project-overlay') + assert.match(overlay, /position:fixed/, 'the layer is taken out of the page flow') + assert.match(overlay, /inset:0/, 'and covers the viewport, not just its own content height') + assert.match(overlay, /align-items:center/, 'the dialog sits centred in that layer') + assert.match(overlay, /justify-content:center/) + // Every other modal in the client pairs a `bt-scrim` sibling with its dialog and dismisses on a + // click; the Project board was the one overlay with no scrim at all. + assert.match(css, /className: 'bt-scrim', onClick: close/, 'the scrim renders beside the dialog and closes the overlay') + // Reuse, not a second visual language: the dialog is kanban's own `bt-mem` surface at kanban's size. + const dialog = rule(css, '.bt-mem.bt-project-dialog') + assert.match(dialog, /width:min\(1080px,calc\(100vw - 32px\)\)/, 'the same dialog width as the Board') + assert.match(dialog, /height:min\(760px,calc\(100vh - 48px\)\)/, 'and the same height, so board rows are not mixed with the shell') + + // The app runs client.js, so the rule only reaches the page once it is in the bundle. + const bundle = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + assert.match(rule(bundle, '.bt-project-overlay'), /position:fixed;inset:0/, 'client.js carries the covering layer') + assert.ok(bundle.includes('bt-mem bt-project-dialog'), 'and the dialog it centres') +})