Skip to content

Commit 7b2a4b4

Browse files
Merge pull request #56 from off-grid-ai/fix/settings-open-scale-jank
fix(ui): Settings sections no longer zoom/stretch on open
2 parents 78785a7 + 997a4ac commit 7b2a4b4

6 files changed

Lines changed: 110 additions & 2 deletions

File tree

217 KB
Loading
246 KB
Loading
219 KB
Loading
183 KB
Loading
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Settings section open/close (accordion drill-in) — behaviour + visible evidence.
3+
*
4+
* Guards the SettingsCard motion fix: opening a section used to scale/zoom the card
5+
* (full framer `layout` animates size via transform: scale(), distorting the text);
6+
* it is now `layout="position"` so content stays crisp and the body height animation
7+
* does the vertical growth. A static screenshot can't show the animation's smoothness,
8+
* but it CAN prove the end states render correctly (crisp, full-width detail, clean
9+
* return to the grid) and that the drill-in/out behaviour works. Captures collapsed →
10+
* mid-transition → open → closed into e2e/screenshots/ for the PR + a vision pass.
11+
*
12+
* Core build (OFFGRID_PRO=0), fresh temp profile, seeded demo data.
13+
*/
14+
import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test'
15+
import os from 'os'
16+
import path from 'path'
17+
import fs from 'fs'
18+
19+
let app: ElectronApplication
20+
let page: Page
21+
let userDataDir: string
22+
23+
const shot = async (name: string): Promise<void> => {
24+
await page.screenshot({ path: `e2e/screenshots/${name}.png` })
25+
}
26+
27+
test.beforeAll(async () => {
28+
userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-settings-motion-'))
29+
app = await electron.launch({
30+
args: ['.'],
31+
env: {
32+
...process.env,
33+
OFFGRID_USER_DATA: userDataDir,
34+
OFFGRID_PRO: '0',
35+
OFFGRID_SEED: 'force',
36+
NODE_ENV: 'production'
37+
}
38+
})
39+
page = await app.firstWindow()
40+
await page.waitForLoadState('domcontentloaded')
41+
// Dismiss onboarding if present.
42+
for (let i = 0; i < 8; i++) {
43+
const btn = page.getByRole('button', { name: /Continue|Start using Off Grid/i })
44+
if (!(await btn.isVisible().catch(() => false))) break
45+
await btn.click().catch(() => {})
46+
await page.waitForTimeout(300)
47+
}
48+
try {
49+
await page.getByRole('button', { name: 'Expand sidebar' }).click({ timeout: 4000 })
50+
} catch {
51+
/* already open */
52+
}
53+
await page.getByRole('button', { name: 'Settings', exact: true }).first().click()
54+
await page.waitForTimeout(800)
55+
})
56+
57+
test.afterAll(async () => {
58+
await app?.close()
59+
try {
60+
fs.rmSync(userDataDir, { recursive: true, force: true })
61+
} catch {
62+
/* ignore */
63+
}
64+
})
65+
66+
test('drills into a Settings section and back without distorting the card', async () => {
67+
// Collapsed grid: several section cards are visible as a scannable master list.
68+
const setupCard = page.getByRole('button', { name: /Setup & health/ }).first()
69+
await expect(setupCard).toBeVisible()
70+
await expect(page.getByRole('heading', { name: 'Setup & health' })).toBeVisible()
71+
await shot('settings-collapsed')
72+
73+
// Open it. Capture a mid-transition frame (during the open animation) then the
74+
// settled detail — so a vision pass can confirm the text is not scaled/zoomed.
75+
await setupCard.click()
76+
await page.waitForTimeout(90)
77+
await shot('settings-opening-midframe')
78+
await page.waitForTimeout(900)
79+
80+
// Open state = the L2 detail: a "back to all settings" affordance appears and the
81+
// card owns the full grid width. The section body content is now visible.
82+
await expect(page.getByText(/All settings/i).first()).toBeVisible()
83+
await shot('settings-section-open')
84+
85+
// The opened card takes the full grid width (col-span-full) — assert it spans most
86+
// of the viewport, proving the detail morph landed rather than staying a grid cell.
87+
const openCard = page.getByRole('heading', { name: 'Setup & health' }).locator('..').locator('..')
88+
const box = await openCard.boundingBox()
89+
const viewport = page.viewportSize()
90+
if (box && viewport) {
91+
expect(box.width).toBeGreaterThan(viewport.width * 0.7)
92+
}
93+
94+
// Close it — the back affordance collapses the detail and the grid returns.
95+
await page.getByText(/All settings/i).first().click()
96+
await page.waitForTimeout(900)
97+
await expect(page.getByText(/All settings/i)).toHaveCount(0)
98+
// Sibling sections are back in the grid (drill-out complete).
99+
await expect(page.getByRole('heading', { name: 'Setup & health' })).toBeVisible()
100+
await shot('settings-collapsed-again')
101+
})

src/renderer/src/components/SettingsCard.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,12 @@ export function SettingsCard({
6464
{!hidden && (
6565
<motion.div
6666
key={title}
67-
layout
67+
// layout="position" (not full `layout`): animate only the card's POSITION as
68+
// siblings reflow. Full `layout` animates SIZE via a transform: scale(), which
69+
// visibly zoomed/stretched the text while a section opened (width -> col-span-full
70+
// and the body height 0->auto happening together). Position-only keeps content
71+
// crisp; the body's own height animation below does the smooth vertical growth.
72+
layout="position"
6873
className={cn(
6974
'overflow-hidden rounded-2xl border border-neutral-800 bg-neutral-900/60 backdrop-blur-sm',
7075
open && group && 'col-span-full' // take over the grid width as the L2 detail
@@ -149,7 +154,9 @@ export function ProPlaceholder({
149154
<AnimatePresence mode="popLayout">
150155
{!hidden && (
151156
<motion.div
152-
layout
157+
// position-only, matching SettingsCard: reflow smoothly without scale-distorting
158+
// the placeholder's text when sibling cards open/close.
159+
layout="position"
153160
className="relative rounded-2xl border border-neutral-800 bg-neutral-900/40 p-6"
154161
initial={{ opacity: 0, filter: 'blur(10px)' }}
155162
animate={{ opacity: 1, filter: 'blur(0px)' }}

0 commit comments

Comments
 (0)