mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-25 02:31:41 +00:00
This repo could never sync. There was NO merge-base with tamagui — 372 commits
here against 15,791 there, unrelated histories — because it began as a
copy-paste rebrand with a flattened layout. Every "sync" since was a hand
re-application that drifted, which is why the tree sat on 7.3.1 while upstream
shipped fixes we then rediscovered by hand.
This commit's PARENT IS TAMAGUI. A future sync is `git merge tama/main` then
`node scripts/rebrand.mjs` — a real merge, because the history is now shared.
174 packages: 166 upstream, our 8 restored (chrome, shell, telemetry, recipes,
get, font-geist-{sans,mono}, @hanzo/booker), bento-get gained. 170/170 build.
Our patches to files UPSTREAM ALSO OWNS are carried, which the package-level
delta missed and which was where the value actually was:
- useMedia's server snapshot (`initState ?? getMedia()`). Upstream still
returns undefined there, so every Tamagui Dialog kills a prerender — the
exact crash hanzo.app spent a day on.
- data-slot on web host nodes, absent upstream entirely.
Both applied ONTO upstream's newer files (it rewrote useMedia +150/-63 and
createComponent +160/-55), marked `PATCH (ours, carried across the transplant)`.
Seven traps are encoded in scripts/rebrand.mjs, each found by a gate:
skip itself and lockfiles; special-case the double-branded names in BOTH scoped
and bare form; map path strings, not just paths; drop a level for the flattened
group; check disk rather than a stale index.
Rust build artifacts are NOT committed. An earlier draft of this commit swept
apps/desktop/src-tauri/target/debug in via `git add -A` — 3,859 files including
a 272MB .a, over GitHub's 100MB cap, so the push could never have landed. They
are gitignored now.
Co-Authored-By: Hanzo Dev <[email protected]>
320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
import { setupPage } from './test-utils'
|
|
|
|
/**
|
|
* PSEUDO TRANSITION TESTS
|
|
*
|
|
* Tests for the `transition` prop inside pseudo-style props (hoverStyle, pressStyle, etc.)
|
|
*
|
|
* CSS semantics:
|
|
* - Enter pseudo state (e.g., hover): use that pseudo's transition (200ms)
|
|
* - Exit pseudo state (e.g., unhover): use base transition (1000ms)
|
|
*
|
|
* These tests run across all animation drivers (css, native, reanimated, motion).
|
|
*/
|
|
|
|
async function getBackgroundColor(page: Page, testId: string): Promise<string> {
|
|
return page.evaluate((id) => {
|
|
const el = document.querySelector(`[data-testid="${id}"]`)
|
|
if (!el) return ''
|
|
return getComputedStyle(el).backgroundColor
|
|
}, testId)
|
|
}
|
|
|
|
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 Number.parseFloat(getComputedStyle(el).opacity)
|
|
}, testId)
|
|
}
|
|
|
|
function parseRgb(color: string): { r: number; g: number; b: number } | null {
|
|
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
|
|
if (!match) return null
|
|
return {
|
|
r: Number.parseInt(match[1], 10),
|
|
g: Number.parseInt(match[2], 10),
|
|
b: Number.parseInt(match[3], 10),
|
|
}
|
|
}
|
|
|
|
test.describe('Pseudo Transition Tests', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
const driver = (test.info().project?.metadata as any)?.animationDriver
|
|
test.skip(driver === 'native', 'native driver has issues on web')
|
|
|
|
await setupPage(page, {
|
|
name: 'PseudoTransitionCase',
|
|
type: 'useCase',
|
|
})
|
|
// wait for initial render
|
|
await page.waitForTimeout(500)
|
|
})
|
|
|
|
test('scenario 1: hoverStyle transition - fast enter (200ms), slow exit (1000ms)', async ({
|
|
page,
|
|
}) => {
|
|
const target = page.getByTestId('scenario-1-target')
|
|
const initialColor = await getBackgroundColor(page, 'scenario-1-target')
|
|
|
|
// hover to trigger enter animation (should be fast - 200ms)
|
|
await target.hover()
|
|
|
|
// wait for enter to complete
|
|
await page.waitForTimeout(350)
|
|
const hoverColor = await getBackgroundColor(page, 'scenario-1-target')
|
|
expect(hoverColor, 'Color should change on hover').not.toBe(initialColor)
|
|
|
|
// move mouse away to trigger exit animation (should be slow - 1000ms)
|
|
await page.mouse.move(0, 0)
|
|
|
|
// at 400ms, exit should still be in progress (1000ms animation)
|
|
await page.waitForTimeout(400)
|
|
const midExitColor = await getBackgroundColor(page, 'scenario-1-target')
|
|
const midExitRgb = parseRgb(midExitColor)
|
|
const hoverRgb = parseRgb(hoverColor)
|
|
const initialRgb = parseRgb(initialColor)
|
|
|
|
// check that exit is still in progress (color not yet back to initial)
|
|
if (midExitRgb && hoverRgb && initialRgb) {
|
|
expect(
|
|
midExitColor,
|
|
'At 400ms into 1000ms exit, color should not be fully reset'
|
|
).not.toBe(initialColor)
|
|
}
|
|
|
|
// wait for full exit
|
|
await page.waitForTimeout(800)
|
|
const finalColor = await getBackgroundColor(page, 'scenario-1-target')
|
|
expect(finalColor, 'Color should return to initial').toBe(initialColor)
|
|
})
|
|
|
|
test('scenario 2: pressStyle transition - fast press (200ms), slow release (1000ms)', async ({
|
|
page,
|
|
}) => {
|
|
const target = page.getByTestId('scenario-2-target')
|
|
const initialColor = await getBackgroundColor(page, 'scenario-2-target')
|
|
|
|
// press to trigger enter animation
|
|
const box = await target.boundingBox()
|
|
if (!box) throw new Error('Target not found')
|
|
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
|
await page.mouse.down()
|
|
|
|
// wait for press animation (200ms + buffer)
|
|
await page.waitForTimeout(350)
|
|
const pressColor = await getBackgroundColor(page, 'scenario-2-target')
|
|
expect(pressColor, 'Color should change on press').not.toBe(initialColor)
|
|
|
|
// release to trigger exit animation (should be slow - 1000ms)
|
|
await page.mouse.up()
|
|
|
|
// at 400ms, exit should still be in progress if using 1000ms base
|
|
await page.waitForTimeout(400)
|
|
const midReleaseColor = await getBackgroundColor(page, 'scenario-2-target')
|
|
|
|
// check exit is still in progress across drivers
|
|
const midRgb = parseRgb(midReleaseColor)
|
|
const pressRgb = parseRgb(pressColor)
|
|
const initRgb = parseRgb(initialColor)
|
|
|
|
if (midRgb && pressRgb && initRgb) {
|
|
expect(
|
|
midReleaseColor,
|
|
'At 400ms into 1000ms release, color should not be fully reset'
|
|
).not.toBe(initialColor)
|
|
}
|
|
|
|
// wait for full exit
|
|
await page.waitForTimeout(800)
|
|
const finalColor = await getBackgroundColor(page, 'scenario-2-target')
|
|
expect(finalColor, 'Color should return to initial').toBe(initialColor)
|
|
})
|
|
|
|
test('scenario 6: opacity hover - fast fade in (200ms), slow fade out (1000ms)', async ({
|
|
page,
|
|
}) => {
|
|
// CSS driver now supports pseudo-class transitions via !important CSS rules
|
|
|
|
const target = page.getByTestId('scenario-6-target')
|
|
const initialOpacity = await getOpacity(page, 'scenario-6-target')
|
|
expect(initialOpacity, 'Initial opacity should be ~0.3').toBeCloseTo(0.3, 1)
|
|
|
|
// hover to trigger opacity increase
|
|
await target.hover()
|
|
|
|
// wait for enter to complete (200ms + buffer for springs)
|
|
await page.waitForTimeout(400)
|
|
const hoverOpacity = await getOpacity(page, 'scenario-6-target')
|
|
expect(hoverOpacity, 'Hover opacity should be > 0.6').toBeGreaterThan(0.6)
|
|
|
|
// move away to trigger opacity decrease (slow - 1000ms)
|
|
await page.mouse.move(0, 0)
|
|
|
|
// at 300ms, opacity should still be elevated if using 1000ms exit
|
|
await page.waitForTimeout(300)
|
|
const midExitOpacity = await getOpacity(page, 'scenario-6-target')
|
|
|
|
expect(
|
|
midExitOpacity,
|
|
'Opacity at 300ms of 1000ms exit should still be elevated'
|
|
).toBeGreaterThan(0.4)
|
|
|
|
// wait for full exit
|
|
await page.waitForTimeout(800)
|
|
const finalOpacity = await getOpacity(page, 'scenario-6-target')
|
|
expect(finalOpacity, 'Final opacity should be ~0.3').toBeCloseTo(0.3, 1)
|
|
})
|
|
|
|
// BUG TEST: First pseudo interaction should use pseudo transition
|
|
// Regression test for: prevPseudoState not initialized, causing first hover to use base transition
|
|
test('first hover should use pseudo transition (not base)', async ({ page }) => {
|
|
const driver = (test.info().project?.metadata as any)?.animationDriver
|
|
|
|
const target = page.getByTestId('scenario-6-target')
|
|
const initialOpacity = await getOpacity(page, 'scenario-6-target')
|
|
expect(initialOpacity, 'Initial opacity should be ~0.3').toBeCloseTo(0.3, 1)
|
|
|
|
// FIRST hover - should use fast 200ms pseudo transition
|
|
await target.hover()
|
|
|
|
// at 350ms, a 200ms animation should be complete (extra buffer for springs)
|
|
await page.waitForTimeout(350)
|
|
const firstHoverOpacity = await getOpacity(page, 'scenario-6-target')
|
|
|
|
// if prevPseudoState wasn't initialized, this would use 1000ms base transition
|
|
// and opacity would still be low (~0.4-0.5). with proper 200ms, should be > 0.6
|
|
expect(
|
|
firstHoverOpacity,
|
|
`First hover should complete quickly with pseudo transition (got ${firstHoverOpacity}, driver: ${driver})`
|
|
).toBeGreaterThan(0.6)
|
|
})
|
|
|
|
// BUG TEST: Exit should use base transition, not cached pseudo transition
|
|
// Regression test for: reanimated keeps pseudo config on exit instead of restoring base
|
|
test('exit should use base transition timing (not cached pseudo)', async ({ page }) => {
|
|
const driver = (test.info().project?.metadata as any)?.animationDriver
|
|
|
|
const target = page.getByTestId('scenario-6-target')
|
|
|
|
// hover to enter (fast 200ms) - wait longer for springs
|
|
await target.hover()
|
|
await page.waitForTimeout(400)
|
|
const hoverOpacity = await getOpacity(page, 'scenario-6-target')
|
|
expect(hoverOpacity, 'Should be at hover state').toBeGreaterThan(0.7)
|
|
|
|
// exit hover - should use SLOW 1000ms base transition
|
|
await page.mouse.move(0, 0)
|
|
|
|
// at 250ms into a 1000ms exit, should still be > 0.4
|
|
// if using cached 200ms pseudo transition, would already be near 0.3
|
|
await page.waitForTimeout(250)
|
|
const midExitOpacity = await getOpacity(page, 'scenario-6-target')
|
|
|
|
expect(
|
|
midExitOpacity,
|
|
`Exit at 250ms of 1000ms should still be > 0.4 (got ${midExitOpacity}, driver: ${driver})`
|
|
).toBeGreaterThan(0.4)
|
|
|
|
// wait for exit to complete
|
|
await page.waitForTimeout(900)
|
|
const finalOpacity = await getOpacity(page, 'scenario-6-target')
|
|
expect(finalOpacity, 'Should return to initial').toBeCloseTo(0.3, 1)
|
|
})
|
|
|
|
test('scenario 6: repeated hover cycles keep slow exit timing (reanimated regression)', async ({
|
|
page,
|
|
}) => {
|
|
const driver = (test.info().project?.metadata as any)?.animationDriver
|
|
test.skip(
|
|
driver !== 'reanimated',
|
|
'Reanimated-specific config cache regression coverage'
|
|
)
|
|
|
|
const target = page.getByTestId('scenario-6-target')
|
|
const initialOpacity = await getOpacity(page, 'scenario-6-target')
|
|
expect(initialOpacity, 'Initial opacity should be ~0.3').toBeCloseTo(0.3, 1)
|
|
|
|
const runCycle = async () => {
|
|
await target.hover()
|
|
await page.waitForTimeout(350)
|
|
const hoveredOpacity = await getOpacity(page, 'scenario-6-target')
|
|
expect(
|
|
hoveredOpacity,
|
|
'Enter should move quickly toward hover opacity'
|
|
).toBeGreaterThan(0.7)
|
|
|
|
await page.mouse.move(0, 0)
|
|
await page.waitForTimeout(300)
|
|
const midExitOpacity = await getOpacity(page, 'scenario-6-target')
|
|
expect(
|
|
midExitOpacity,
|
|
'Exit should still be in progress at 300ms (base 1000ms transition)'
|
|
).toBeGreaterThan(0.5)
|
|
|
|
await page.waitForTimeout(900)
|
|
const finalOpacity = await getOpacity(page, 'scenario-6-target')
|
|
expect(finalOpacity, 'Cycle should return to base opacity').toBeCloseTo(0.3, 1)
|
|
}
|
|
|
|
await runCycle()
|
|
await runCycle()
|
|
})
|
|
|
|
test('scenario 4: group hover transition - fast enter (200ms)', async ({ page }) => {
|
|
const driver = (test.info().project?.metadata as any)?.animationDriver
|
|
|
|
const container = page.getByTestId('scenario-4-container')
|
|
const initialOpacity = await getOpacity(page, 'scenario-4-target')
|
|
expect(initialOpacity, 'Initial opacity should be ~0.3').toBeCloseTo(0.3, 1)
|
|
|
|
// hover over container to trigger group hover (should be fast - 200ms)
|
|
await container.hover()
|
|
|
|
// at 350ms, a 200ms animation should be complete (extra buffer for springs)
|
|
await page.waitForTimeout(350)
|
|
const hoverOpacity = await getOpacity(page, 'scenario-4-target')
|
|
|
|
// if using 200ms transition, opacity should be close to 1 (>0.8)
|
|
// if using 1000ms base transition incorrectly, would be ~0.5
|
|
expect(
|
|
hoverOpacity,
|
|
`Group hover enter should complete quickly with 200ms transition (got ${hoverOpacity}, driver: ${driver})`
|
|
).toBeGreaterThan(0.8)
|
|
})
|
|
|
|
test('scenario 4: group hover transition - slow exit (1000ms base)', async ({
|
|
page,
|
|
}) => {
|
|
const container = page.getByTestId('scenario-4-container')
|
|
const initialOpacity = await getOpacity(page, 'scenario-4-target')
|
|
expect(initialOpacity, 'Initial opacity should be ~0.3').toBeCloseTo(0.3, 1)
|
|
|
|
// hover over container to trigger group hover
|
|
await container.hover()
|
|
|
|
// wait for hover state to be fully applied (use longer time for all drivers)
|
|
await page.waitForTimeout(1200)
|
|
const hoverOpacity = await getOpacity(page, 'scenario-4-target')
|
|
expect(hoverOpacity, 'Hover opacity should be ~1').toBeGreaterThan(0.9)
|
|
|
|
// move away to trigger exit (slow - 1000ms base transition)
|
|
await page.mouse.move(0, 0)
|
|
|
|
// KEY TEST: at 300ms into 1000ms linear exit, opacity should still be > 0.5
|
|
await page.waitForTimeout(300)
|
|
const midExitOpacity = await getOpacity(page, 'scenario-4-target')
|
|
expect(
|
|
midExitOpacity,
|
|
'Opacity should still be elevated during slow group-hover exit'
|
|
).toBeGreaterThan(0.5)
|
|
|
|
// wait for full exit
|
|
await page.waitForTimeout(900)
|
|
const finalOpacity = await getOpacity(page, 'scenario-4-target')
|
|
expect(finalOpacity, 'Final opacity should be ~0.3').toBeCloseTo(0.3, 1)
|
|
})
|
|
})
|