mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-30 06:05:14 +00:00
Match the layout convention used elsewhere in the Hanzo / Liquidity stack
(pkgs/* for libraries, apps/* for consumer apps, templates/* for starters).
Layout changes:
pkgs/ ← code/{compiler,core,packages,ui,ui-admin}/
apps/ ← code/{admin-base,admin-tasks,demos,gui.hanzo.ai,
kitchen-sink*,sandbox,tests}/
templates/ ← code/starters/* (flattened — no more starters/ subdir)
Updated:
- package.json workspaces glob.
- All scripts/* path references (postinstall, format, repair, release,
sync-gui-versions, ci-serve, check-references, etc).
- ../../tasks/scripts/sync-admin-ui.sh — DEFAULT_SRC now points at
../gui/apps/admin-tasks (and scrubbed Tamagui mentions in comments).
- ~/work/hanzo/HANZO_BINARY.md doctrine doc.
- ~/work/hanzo/ui/pkg/ui/BASES.md framework-bases doc.
- gui CLAUDE.md, CONTRIBUTING.md.
- Memory entries.
Side-effect fix: ui-admin/src/primitives/index.ts now re-exports
PageShell from ../shell/PageShell. Several IAM identity pages
(GroupEdit, OrgEdit, GroupTree, UserEdit, RoleEdit) imported PageShell
from ./primitives — pre-existing barrel mismatch the test suite missed
because the smoke tests don't full-render. The rename surfaced it via
production build; back-compat re-export keeps both barrels working.
Verification:
- bun install: clean.
- ui-admin tests: 127/127 green.
- admin-tasks vite build: 721 KB JS / 213 KB gzip + 10 KB CSS, clean.
177 lines
6.1 KiB
TypeScript
177 lines
6.1 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
import { setupPage } from './test-utils'
|
|
|
|
async function getOpacity(page: Page, testId: string): Promise<number> {
|
|
return page.evaluate((id) => {
|
|
const el = document.querySelector(`[data-testid="${id}"]`)
|
|
if (!el) return -1
|
|
return parseFloat(getComputedStyle(el).opacity)
|
|
}, testId)
|
|
}
|
|
|
|
async function elementExists(page: Page, testId: string): Promise<boolean> {
|
|
return page.evaluate((id) => !!document.querySelector(`[data-testid="${id}"]`), testId)
|
|
}
|
|
|
|
async function waitForOpacity(
|
|
page: Page,
|
|
testId: string,
|
|
target: number,
|
|
timeout = 3000
|
|
) {
|
|
const tolerance = target === 1 ? 0.02 : 0.01
|
|
const start = Date.now()
|
|
while (Date.now() - start < timeout) {
|
|
const opacity = await getOpacity(page, testId)
|
|
if (Math.abs(opacity - target) < tolerance) return opacity
|
|
await page.waitForTimeout(50)
|
|
}
|
|
return getOpacity(page, testId)
|
|
}
|
|
|
|
async function waitForRemoval(page: Page, testId: string, timeout = 3000) {
|
|
const start = Date.now()
|
|
while (Date.now() - start < timeout) {
|
|
if (!(await elementExists(page, testId))) return true
|
|
await page.waitForTimeout(50)
|
|
}
|
|
return false
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await setupPage(page, { name: 'AnimatePresenceEnterExitCase', type: 'useCase' })
|
|
await page.waitForTimeout(500)
|
|
})
|
|
|
|
test('scenario 01: enter animation should animate opacity from 0 to 1', async ({
|
|
page,
|
|
}) => {
|
|
expect(await elementExists(page, 'enter-exit-01-target')).toBe(false)
|
|
|
|
await page.getByTestId('enter-exit-01-trigger').click()
|
|
|
|
// check intermediate opacity during animation
|
|
await page.waitForTimeout(50)
|
|
const midOpacity = await getOpacity(page, 'enter-exit-01-target')
|
|
expect(midOpacity).toBeGreaterThanOrEqual(0)
|
|
expect(midOpacity).toBeLessThan(1)
|
|
|
|
// wait for animation to settle near 1
|
|
const finalOpacity = await waitForOpacity(page, 'enter-exit-01-target', 1)
|
|
expect(finalOpacity).toBeGreaterThan(0.98)
|
|
})
|
|
|
|
test('scenario 01: exit animation should animate opacity from 1 to 0', async ({
|
|
page,
|
|
}) => {
|
|
await page.getByTestId('enter-exit-01-trigger').click()
|
|
const enterOpacity = await waitForOpacity(page, 'enter-exit-01-target', 1)
|
|
expect(enterOpacity).toBeGreaterThan(0.98)
|
|
|
|
await page.getByTestId('enter-exit-01-trigger').click()
|
|
|
|
// during exit, element should still exist but opacity should be less than 1
|
|
await page.waitForTimeout(50)
|
|
const midOpacity = await getOpacity(page, 'enter-exit-01-target')
|
|
expect(await elementExists(page, 'enter-exit-01-target')).toBe(true)
|
|
expect(midOpacity).toBeLessThan(1)
|
|
expect(midOpacity).toBeGreaterThanOrEqual(0)
|
|
|
|
// wait for exit to complete - element should be removed
|
|
expect(await waitForRemoval(page, 'enter-exit-01-target')).toBe(true)
|
|
})
|
|
|
|
test('scenario 02: circle badge enter animation', async ({ page }) => {
|
|
expect(await elementExists(page, 'enter-exit-02-target')).toBe(false)
|
|
|
|
await page.getByTestId('enter-exit-02-increment').click()
|
|
|
|
await page.waitForTimeout(50)
|
|
const midOpacity = await getOpacity(page, 'enter-exit-02-target')
|
|
expect(midOpacity).toBeGreaterThanOrEqual(0)
|
|
expect(midOpacity).toBeLessThan(1)
|
|
|
|
const finalOpacity = await waitForOpacity(page, 'enter-exit-02-target', 1)
|
|
expect(finalOpacity).toBeGreaterThan(0.98)
|
|
})
|
|
|
|
test('scenario 03: initial=false enter animation', async ({ page }) => {
|
|
expect(await elementExists(page, 'enter-exit-03-target')).toBe(false)
|
|
|
|
await page.getByTestId('enter-exit-03-increment').click()
|
|
|
|
await page.waitForTimeout(50)
|
|
const midOpacity = await getOpacity(page, 'enter-exit-03-target')
|
|
expect(midOpacity).toBeGreaterThanOrEqual(0)
|
|
expect(midOpacity).toBeLessThan(1)
|
|
|
|
const finalOpacity = await waitForOpacity(page, 'enter-exit-03-target', 1)
|
|
expect(finalOpacity).toBeGreaterThan(0.98)
|
|
})
|
|
|
|
test('scenario 03: initial=false enter has intermediate frames', async ({ page }) => {
|
|
await page.evaluate(() => {
|
|
;(window as any).__opacityFrames03 = []
|
|
;(window as any).__rafRunning03 = true
|
|
const track = () => {
|
|
if (!(window as any).__rafRunning03) return
|
|
const el = document.querySelector('[data-testid="enter-exit-03-target"]')
|
|
if (el) {
|
|
;(window as any).__opacityFrames03.push(parseFloat(getComputedStyle(el).opacity))
|
|
}
|
|
requestAnimationFrame(track)
|
|
}
|
|
requestAnimationFrame(track)
|
|
})
|
|
|
|
await page.getByTestId('enter-exit-03-increment').click()
|
|
await page.waitForTimeout(500)
|
|
|
|
await page.evaluate(() => {
|
|
;(window as any).__rafRunning03 = false
|
|
})
|
|
|
|
const frames: number[] = await page.evaluate(() => (window as any).__opacityFrames03)
|
|
|
|
expect(frames.length).toBeGreaterThan(3)
|
|
|
|
const intermediateFrames = frames.filter((f) => f > 0.05 && f < 0.95)
|
|
expect(
|
|
intermediateFrames.length,
|
|
`expected intermediate opacity frames, got: ${JSON.stringify(frames.slice(0, 20))}`
|
|
).toBeGreaterThan(0)
|
|
|
|
// final frame should be near 1 (spring may not land exactly on 1)
|
|
expect(frames[frames.length - 1]).toBeGreaterThan(0.95)
|
|
})
|
|
|
|
test('scenario 03: initial=false exit animation', async ({ page }) => {
|
|
await page.getByTestId('enter-exit-03-increment').click()
|
|
const enterOpacity = await waitForOpacity(page, 'enter-exit-03-target', 1)
|
|
expect(enterOpacity).toBeGreaterThan(0.98)
|
|
|
|
await page.getByTestId('enter-exit-03-clear').click()
|
|
|
|
await page.waitForTimeout(50)
|
|
const midOpacity = await getOpacity(page, 'enter-exit-03-target')
|
|
expect(await elementExists(page, 'enter-exit-03-target')).toBe(true)
|
|
expect(midOpacity).toBeLessThan(1)
|
|
|
|
expect(await waitForRemoval(page, 'enter-exit-03-target')).toBe(true)
|
|
})
|
|
|
|
test('scenario 02: circle badge exit animation', async ({ page }) => {
|
|
await page.getByTestId('enter-exit-02-increment').click()
|
|
const enterOpacity = await waitForOpacity(page, 'enter-exit-02-target', 1)
|
|
expect(enterOpacity).toBeGreaterThan(0.98)
|
|
|
|
await page.getByTestId('enter-exit-02-clear').click()
|
|
|
|
await page.waitForTimeout(50)
|
|
const midOpacity = await getOpacity(page, 'enter-exit-02-target')
|
|
expect(await elementExists(page, 'enter-exit-02-target')).toBe(true)
|
|
expect(midOpacity).toBeLessThan(1)
|
|
|
|
expect(await waitForRemoval(page, 'enter-exit-02-target')).toBe(true)
|
|
})
|