Computed flow layout, and mobile written into the design

The canvas lays itself out: a layered graph, left to right on a desktop and
top to bottom on a phone, with room reserved for the value each edge carries.
Nodes cannot be dragged and `NodeDef.position` is gone from the document —
a graph nobody can arrange is one worth keeping small, which is what keeps
flows atomic. Endpoints join the same layout, so their lanes and the
localStorage that remembered where they were dragged go too.

Mobile, per the new Responsive section of DESIGN-GUIDELINES.md: the dock caps
its width and wraps instead of running off the screen, the dashboard stacks
into one column rather than shrinking a wall panel to a fifth of its size, and
Home stops widening its grid track past the viewport. A Playwright project at
a phone's width fails the build when a screen no longer fits.

Along the way: publish is the checkmark that was already there rather than a
button that appears and disappears, with discard beside it on both the flow
and the dashboard; the brain reveals a neuron's name on the first tap; and the
port sparklines get room to breathe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto
This commit is contained in:
2026-08-17 17:35:14 +02:00
co-authored by Claude Opus 5
parent 1c09b8209d
commit bb90a24b90
37 changed files with 998 additions and 527 deletions
+46 -32
View File
@@ -25,11 +25,23 @@ if (!EMAIL || !PASSWORD) {
process.exit(1)
}
/**
* The two shapes the app is drawn for: a desktop, and a phone. Below `md` it
* is a different layout rather than a narrower one — see the Responsive
* section of the root DESIGN-GUIDELINES.md — so it wants its own shots.
*/
const VIEWPORTS = [
{ name: "", viewport: { width: 1440, height: 900 } },
{ name: "mobile", viewport: { width: 390, height: 844 } },
]
/** Force the theme through the same storage key the pre-paint script reads. */
async function withTheme(browser, theme) {
async function withTheme(browser, theme, viewport) {
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
viewport,
colorScheme: theme,
isMobile: viewport.width < 768,
hasTouch: viewport.width < 768,
})
await context.addInitScript((t) => {
localStorage.setItem("fluksio-ui-theme", t)
@@ -46,41 +58,43 @@ const browser = await chromium.launch(
)
for (const theme of ["light", "dark"]) {
const dir = `${OUT}/${theme}`
await mkdir(dir, { recursive: true })
const context = await withTheme(browser, theme)
const page = await context.newPage()
for (const { name, viewport } of VIEWPORTS) {
const dir = name ? `${OUT}/${theme}/${name}` : `${OUT}/${theme}`
await mkdir(dir, { recursive: true })
const context = await withTheme(browser, theme, viewport)
const page = await context.newPage()
await page.goto(`${WEBSITE_URL}/`, { waitUntil: "networkidle" })
// networkidle fires before the staggered entrance animations settle, which
// would capture buttons mid-fade and make contrast look broken.
await page.waitForTimeout(1500)
await page.screenshot({ path: `${dir}/website-hero.png` })
await page.goto(`${WEBSITE_URL}/`, { waitUntil: "networkidle" })
// networkidle fires before the staggered entrance animations settle, which
// would capture buttons mid-fade and make contrast look broken.
await page.waitForTimeout(1500)
await page.screenshot({ path: `${dir}/website-hero.png` })
await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" })
await page.screenshot({ path: `${dir}/app-login.png` })
await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" })
await page.screenshot({ path: `${dir}/app-login.png` })
await page.getByTestId("email-input").fill(EMAIL)
await page.getByTestId("password-input").fill(PASSWORD)
await page.getByRole("button", { name: /log in/i }).click()
await page.waitForURL(`${APP_URL}/`, { timeout: 15000 })
await page.waitForLoadState("networkidle")
// Home's sections fetch independently, so networkidle can fall between them
// and photograph the skeletons. The flow table is the last of them to land.
await page
.getByText(/Flow activity/i)
.first()
.waitFor({ timeout: 15000 })
await page.waitForTimeout(1500)
await page.screenshot({ path: `${dir}/app-dashboard.png` })
await page.getByTestId("email-input").fill(EMAIL)
await page.getByTestId("password-input").fill(PASSWORD)
await page.getByRole("button", { name: /log in/i }).click()
await page.waitForURL(`${APP_URL}/`, { timeout: 15000 })
await page.waitForLoadState("networkidle")
// Home's sections fetch independently, so networkidle can fall between them
// and photograph the skeletons. The flow table is the last of them to land.
await page
.getByText(/Flow activity/i)
.first()
.waitFor({ timeout: 15000 })
await page.waitForTimeout(1500)
await page.screenshot({ path: `${dir}/app-dashboard.png` })
await captureFlows(page, dir)
await captureDashboards(page, dir)
await captureFlows(page, dir)
await captureDashboards(page, dir)
await context.close()
console.log(
` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel}.png`,
)
await context.close()
console.log(
` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel}.png`,
)
}
}
await browser.close()