Skip to content
Closed
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
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

# Supersede in-flight runs for the same branch/PR; the latest push is the one
# whose result matters.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
name: Lint, typecheck, test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
# Matches the workspace engines field (>=24.17.0 <25) and the
# deploy-pages workflow.
node-version: '24'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Typecheck
run: npm run typecheck

# Runs last so a failure here is not masked by an earlier step, and each
# step reports independently in the checks UI.
- name: Unit tests
run: npm test
81 changes: 60 additions & 21 deletions packages/builder-ui/src/theme/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ body {
--r-panel: 20px;
--duration-fast: 160ms;
--duration-med: 220ms;
/* Floor for the workspace's single grid row: ~220px of usable canvas plus the
18px gap and the content-sized preview card beneath it. Below this the
workspace scrolls rather than compressing the canvas out of existence. */
--eb-workspace-min-height: 460px;
/* dvh tracks the visible viewport as mobile browser chrome shows/hides;
the vh line is the fallback for engines without dvh support. */
height: 100vh;
Expand Down Expand Up @@ -277,11 +281,22 @@ body {
grid-template-columns: var(--eb-left-dock-width) minmax(0, 1fr) var(--eb-right-dock-width);
/* Bound the single row to the container so tall content doesn't stretch the
row (auto rows grow with content). Without this the canvas/preview cards
grow past the viewport and their inner bodies never scroll. */
grid-template-rows: minmax(0, 1fr);
grow past the viewport and their inner bodies never scroll.

The floor is the height half of the same complaint: .eb-canvas-card is the
only flexible item in .eb-center-col (the preview card is flex: 0 0 auto and
content-sized), so a short container made the canvas absorb the entire
shortfall and collapse to a sliver with its rules hidden behind inner
scrollbars. This is not a narrow-screen case — PPTB hosts the tool in an
iframe that is routinely wide but short — so it is fixed here rather than
behind a width breakpoint. When there is ample height 1fr wins and the
computed layout is unchanged; when there is not, overflow-y scrolls the
workspace instead of crushing the canvas. */
grid-template-rows: minmax(var(--eb-workspace-min-height), 1fr);
gap: 18px;
padding: 18px;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
transition: grid-template-columns var(--duration-med) ease, gap var(--duration-med) ease;
}

Expand Down Expand Up @@ -357,6 +372,16 @@ body {
padding: 10px 12px 0;
background: var(--surface);
flex: 0 0 auto;
overflow-x: auto;
/* The strip is only as tall as its 34px tabs, so a visible horizontal
scrollbar would push the active tab's border-bottom off the pane edge and
break the tab/panel join. Tabs stay reachable by pointer drag and by the
strip's arrow-key navigation. */
scrollbar-width: none;
}

.eb-tab-strip::-webkit-scrollbar {
display: none;
}

.eb-tab-strip button {
Expand All @@ -368,6 +393,7 @@ body {
color: var(--text3);
font-size: 0.82rem;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
transition: all var(--duration-fast) ease;
}
Expand Down Expand Up @@ -575,7 +601,9 @@ body {
.eb-group-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
row-gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
Expand Down Expand Up @@ -1648,6 +1676,13 @@ body {
.eb-rule-tools {
grid-column: 3 / -1;
}

/* Once the toolbar wraps, send the caption to its own full-width row so the
+ Rule / + Group actions keep the first row to themselves. */
.eb-group-caption {
order: 3;
flex: 1 1 100%;
}
}

@media (max-width: 900px) {
Expand Down Expand Up @@ -1690,13 +1725,29 @@ body {
flex-basis: auto;
}

/* Stacked layout scrolls as one page. The desktop chain nests two scroll
containers (.eb-pane-body, then .eb-group-children) inside a column that
was pinned to a share of the viewport, so on a short window the builder
became a sliver with its rules hidden behind inner scrollbars. Sizing the
column and the canvas to their content instead hands every pane to
.eb-workspace's own overflow-y.

The old viewport-share floor existed because .eb-canvas-card was
flex: 1 1 auto with min-height: 0, contributing nothing to the grid's
auto row and collapsing it; flex: 0 0 auto restores a real intrinsic
contribution, so the floor is no longer needed to hold the column open. */
.eb-center-col {
order: -1;
/* The grid's auto row undersizes this nested flex column (its children's
min-heights don't propagate into the row's intrinsic contribution and it
collapses to a sliver), so give the stacked canvas + preview an explicit
share of the viewport; the canvas body scrolls internally. */
min-height: min(70vh, 560px);
overflow: visible;
}

.eb-canvas-card {
flex: 0 0 auto;
overflow: visible;
}

.eb-canvas-card .eb-pane-body,
.eb-group-children {
overflow: visible;
}
}

Expand Down Expand Up @@ -1751,18 +1802,6 @@ body {
font-size: 0.8rem;
}

/* Group toolbar (AND/OR pill, caption, actions) wraps instead of pushing
its + Rule / + Group buttons off the right edge. */
.eb-group-toolbar {
flex-wrap: wrap;
row-gap: 8px;
}

.eb-group-caption {
order: 3;
flex: 1 1 100%;
}

/* Rule rows stack: field on the glyph row, then operator, value, tools —
each on its own full-width line instead of a cramped multi-column grid. */
.eb-rule-row-editor {
Expand Down
142 changes: 142 additions & 0 deletions packages/builder-ui/test/dragDropStyles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,49 @@ function readSource(relativePath: string): string {
return readFileSync(resolve(sourceRoot, relativePath), 'utf8');
}

function mediaBlock(query: string): string {
const start = css.indexOf(`@media (${query})`);
if (start === -1) throw new Error(`Missing media query: ${query}`);
const open = css.indexOf('{', start);
let depth = 0;
for (let index = open; index < css.length; index += 1) {
if (css[index] === '{') depth += 1;
else if (css[index] === '}') {
depth -= 1;
if (depth === 0) return css.slice(open + 1, index);
}
}
throw new Error(`Unterminated media query: ${query}`);
}

/** tokens.css with every @media block removed: the rules that apply at any size. */
function unscopedCss(): string {
let out = '';
let index = 0;
while (index < css.length) {
const start = css.indexOf('@media', index);
if (start === -1) {
out += css.slice(index);
break;
}
out += css.slice(index, start);
const open = css.indexOf('{', start);
let depth = 0;
for (let scan = open; scan < css.length; scan += 1) {
if (css[scan] === '{') depth += 1;
else if (css[scan] === '}') {
depth -= 1;
if (depth === 0) {
index = scan + 1;
break;
}
}
}
if (depth !== 0) throw new Error('Unterminated media query');
}
return out;
}

function declarationBlock(selectorFragment: string): string {
const rule = cssRules.find(({ selectors }) =>
selectors.includes(selectorFragment),
Expand Down Expand Up @@ -214,4 +257,103 @@ describe('drag-and-drop visual contract', () => {
/@media\s*\(max-width:\s*900px\)\s*\{[\s\S]*?\.eb-workspace\s*\{[\s\S]*?grid-template-columns:\s*1fr\s*;/,
);
});

it('keeps the Toolbox first in the stacked layout', () => {
// The workspace renders Toolbox -> center column -> inspector, so the
// stacked layout must not reorder the center column ahead of the Toolbox:
// users landing on a narrow window need the field list, not the canvas.
const stacked = mediaBlock('max-width: 900px');
expect(stacked).toMatch(/\.eb-center-col\s*\{/);
expect(stacked).not.toMatch(/\.eb-center-col\s*\{[^}]*\border\s*:/);
});

it('scrolls the tab strip without breaking the active tab join', () => {
const strip = declarationBlock('.eb-tab-strip');
expect(strip).toMatch(/\boverflow-x:\s*auto\s*;/);
// A visible scrollbar inside the 34px-tall strip would detach the active
// tab's border-bottom from the pane below it.
expect(strip).toMatch(/\bscrollbar-width:\s*none\s*;/);
expect(declarationBlock('.eb-tab-strip::-webkit-scrollbar')).toMatch(
/\bdisplay:\s*none\s*;/,
);
// Labels must not wrap, or the strip grows instead of scrolling.
expect(declarationBlock('.eb-tab-strip button')).toMatch(
/\bwhite-space:\s*nowrap\s*;/,
);
});

it('scrolls the stacked layout as one page instead of nesting scrollers', () => {
const stacked = mediaBlock('max-width: 900px');

// .eb-workspace owns the only scroll below 900px.
expect(stacked).toMatch(/\.eb-workspace\s*\{[^}]*overflow-y:\s*auto\s*;/);

// The canvas must contribute its real height to the grid's auto row.
// flex: 1 1 auto with min-height: 0 contributes nothing and collapses the
// row, which is what the old min(70vh, 560px) floor was propping up.
expect(stacked).toMatch(/\.eb-canvas-card\s*\{[^}]*flex:\s*0 0 auto\s*;/);
expect(stacked).not.toMatch(/min-height:\s*min\(/);

// The desktop scroll chain (.eb-pane-body -> .eb-group-children) stands
// down, or a short window hides rules behind two nested scrollbars.
expect(stacked).toMatch(/\.eb-center-col\s*\{[^}]*overflow:\s*visible\s*;/);
expect(stacked).toMatch(
/\.eb-canvas-card \.eb-pane-body,\s*\.eb-group-children\s*\{[^}]*overflow:\s*visible\s*;/,
);

// Desktop keeps per-container scrolling. declarationBlock() reads the first
// rule in file order (the base rule), so this pins the base declarations
// against an in-place edit; the unscoped sweep below is what catches the
// same override being duplicated outside the media query.
expect(declarationBlock('.eb-pane-body')).toMatch(/\boverflow:\s*auto\s*;/);
expect(declarationBlock('.eb-group-children')).toMatch(/\boverflow:\s*auto\s*;/);

// The stand-down must stay media-scoped. A duplicate rule added later in the
// file would win on source order and silently unconstrain the desktop cards,
// and declarationBlock() cannot see past the first match.
const desktop = unscopedCss();
expect(desktop).not.toMatch(
/\.eb-(pane-body|group-children|center-col)[^{}]*\{[^}]*overflow:\s*visible\s*;/,
);

// Load-bearing on the desktop path: a scrolling center column unconstrains
// the canvas/preview cards so their inner bodies stop scrolling.
expect(declarationBlock('.eb-center-col')).toMatch(/\boverflow:\s*hidden\s*;/);
});

it('floors the workspace row so a short host never crushes the canvas', () => {
// Height, unlike width, is not a narrow-screen concern: PPTB hosts the tool
// in an iframe that is routinely wide but short, so this fix must not sit
// behind a width breakpoint. .eb-canvas-card is the only flexible item in
// .eb-center-col, so without a floor it absorbs the whole shortfall.
const workspace = declarationBlock('.eb-workspace');
expect(workspace).toMatch(
/grid-template-rows:\s*minmax\(var\(--eb-workspace-min-height\),\s*1fr\)\s*;/,
);
expect(workspace).toMatch(/overflow-y:\s*auto\s*;/);
expect(workspace).not.toMatch(/\boverflow:\s*hidden\s*;/);

// A real floor, not a placeholder — the canvas needs ~220px to show rules.
const floor = /--eb-workspace-min-height:\s*(\d+)px\s*;/.exec(
declarationBlock('.eb-root'),
);
expect(floor).not.toBeNull();
expect(Number(floor![1])).toBeGreaterThanOrEqual(400);
});

it('wraps the group toolbar at every width so its actions never clip', () => {
// .eb-group-card sets overflow: hidden, and the dock widths are inline
// styles that collapse independently of the viewport, so the wrap cannot
// be gated behind a breakpoint without leaving widths where + Rule and
// + Group get clipped.
expect(declarationBlock('.eb-group-toolbar')).toMatch(
/\bflex-wrap:\s*wrap\s*;/,
);
expect(declarationBlock('.eb-group-card')).toMatch(
/\boverflow:\s*hidden\s*;/,
);
expect(mediaBlock('max-width: 1100px')).toMatch(
/\.eb-group-caption\s*\{[^}]*flex:\s*1 1 100%/,
);
});
});
Loading