Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1639a19
feat: calibrate iOS 26 controls with native parity probes
rdlabo Sep 15, 2026
2590024
feat: calibrate iOS 26 tab geometry and tap motion
rdlabo Sep 15, 2026
ea8daa7
wip: snapshot iOS 26 native parity for review
rdlabo Sep 15, 2026
23b3554
fix: remove added button and overlay APIs
rdlabo Sep 15, 2026
ae4e37f
test: keep only maintainable theme regressions
rdlabo Sep 15, 2026
983ab63
Align iOS 26 theme customization with main design
rdlabo Sep 15, 2026
9cb1231
Simplify iOS 26 parity refinements to main's complexity level
rdlabo Sep 15, 2026
10cb359
Fix routed tab convergence and selected surface layering
rdlabo Sep 15, 2026
dc693d7
fix(ios26): constrain soft button shadows to toolbar clearance
rdlabo Sep 15, 2026
e8d3ac5
fix(ios26): keep tab glass tracking outside the bar
rdlabo Sep 15, 2026
d7101e3
fix(ios26): preserve large title geometry during navigation
rdlabo Sep 15, 2026
6537c40
fix: keep translucent navigation pages visually continuous
rdlabo Sep 15, 2026
3325f6e
feat(demo): add Push navigation from button to action sheet
rdlabo Sep 15, 2026
e10aee2
fix: match native navigation dimming and page outlines
rdlabo Sep 15, 2026
9a95834
refactor: use shared theme utilities
rdlabo Sep 15, 2026
79ad007
fix: update shared utility resolution
rdlabo Sep 15, 2026
7870467
fix: consume compiled utility entrypoint
rdlabo Sep 15, 2026
721a0ae
refactor: align iOS theme implementation structure
rdlabo Sep 15, 2026
bc1cbcf
fix: stabilize screenshot and e2e workflows
rdlabo Sep 15, 2026
ac7fa1c
ci: reduce and parallelize Playwright coverage
rdlabo Sep 15, 2026
aa6e4c4
fix: invoke Ionic 8 screenshot suite directly
rdlabo Sep 15, 2026
b4ceacb
fix: forward screenshot spec through npm
rdlabo Sep 15, 2026
fb395f0
Merge remote-tracking branch 'origin/ios26' into review/ios26-native-…
rdlabo Sep 15, 2026
025f8ed
fix: order screenshot update arguments
rdlabo Sep 15, 2026
70465c3
Merge remote-tracking branch 'origin/ios26' into review/ios26-native-…
rdlabo Sep 15, 2026
3ebbd67
fix: align Ionic 8 textarea height with Ionic 9
rdlabo Sep 15, 2026
1ce6907
chore: update E2E screenshots
github-actions[bot] Sep 15, 2026
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
9 changes: 7 additions & 2 deletions .github/workflows/e2e-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
- ios26
jobs:
test:
timeout-minutes: 10
timeout-minutes: 20
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand Down Expand Up @@ -45,7 +45,12 @@ jobs:
working-directory: './demo'

- name: Run Playwright tests
run: npm run test:e2e
run: |
if [ "${{ matrix.ionic-major }}" = "8" ]; then
npm run test:e2e -- e2e/screenshot.spec.ts
else
npm run test:e2e
fi
working-directory: ./demo
env:
IONIC_MAJOR: ${{ matrix.ionic-major }}
Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/e2e-pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ permissions:
jobs:
test:
if: github.event.action != 'closed'
timeout-minutes: 10
timeout-minutes: 20
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down Expand Up @@ -53,7 +53,12 @@ jobs:
working-directory: './demo'

- name: Run Playwright tests
run: PLAYWRIGHT_JSON_OUTPUT_NAME=e2e/screenshot.spec.ts-ionic${{ matrix.ionic-major }}.json npm run test:e2e -- --reporter=json,html
run: |
if [ "${{ matrix.ionic-major }}" = "8" ]; then
PLAYWRIGHT_JSON_OUTPUT_NAME=e2e/screenshot.spec.ts-ionic8.json npm run test:e2e -- e2e/screenshot.spec.ts --reporter=json,html
else
PLAYWRIGHT_JSON_OUTPUT_NAME=e2e/screenshot.spec.ts-ionic9.json npm run test:e2e -- --reporter=json,html
fi
working-directory: ./demo
env:
IONIC_MAJOR: ${{ matrix.ionic-major }}
Expand Down
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
node_modules
dist
www
demo/playwright-report
demo/test-results
29 changes: 29 additions & 0 deletions demo/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Theme regression tests

Use the existing Playwright command: `npm --prefix demo run test:e2e`.
The existing CI matrix runs against actual Ionic 8 and 9 packages.

`screenshot.spec.ts` owns static light/dark appearance. The additional tests
protect behavior introduced or overridden by this theme, not Ionic itself:

| Spec | Regression worth maintaining |
| --- | --- |
| `ios26-submit-brightness` | Public brightness colors, legacy alias, submit markup, disabled styling and Ionic glass-button overrides |
| `ios26-child-optout` | Nested selectors leaking theme styles into opted-out children |
| `ios26-popover-position` | Theme positioning losing event coordinates, clipping content or retaining temporary widths |
| `ios26-segment-parity` | Selection effects duplicating events/lenses or ignoring reduced motion/custom styles |
| `ios26-tab-lifecycle` | 1–5 tab layout (five without FAB), narrow-width containment/tablet caps, background overrides, click ownership and cleanup |
| `ios26-range-parity` | Enlarged thumbs moving past LTR/RTL endpoints or deforming the inactive dual thumb |
| `ios26-navigation` | Title/header movement, full-height dimming without page transparency or leftover layers, and pop restoring the original scroll position |
| `toggle` | Short-tap effects not settling, or CSS overriding reduced motion/public styling |

Do not duplicate screenshot coverage with lists of CSS constants or re-test
Ionic's unmodified behavior. Prefer relations (containment, unchanged value,
single event, restored style) over implementation-specific animation samples.
Keep numeric layout cases for layout safety, not fractional native discontinuities.

One-off native probes, copied Shell sources, raw frame/width dumps and their
runner/reporting stack were removed. Their last snapshot is commit `23b3554`
(`demo/native-parity`); it is historical evidence, not another maintained suite.
Tab sizing follows main's shared overlap layout instead of replaying the native width table.
Passing browser regressions does not establish native pixel or motion parity.
73 changes: 73 additions & 0 deletions demo/e2e/ios26-child-optout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test';

// Compare opted-out children with an unthemed sibling, not Ionic's current px values.
// Structural selectors are identical in light/dark, so do not multiply that axis.
for (const optOut of ['ios-theme-disabled', 'ios26-disabled']) {
test(`${optOut} isolates card item/list padding from themed siblings`, async ({ page }) => {
await page.goto('/main/index/card');
await expect(page.locator('app-card ion-card').first()).toHaveClass(/hydrated/);
await page.evaluate((cls) => {
document.querySelector('ion-app')!.insertAdjacentHTML(
'beforeend',
`
<div id="optout-fixture">
<ion-card><ion-card-content>
<ion-list>
<ion-item id="themed"><ion-label>Themed</ion-label></ion-item>
<ion-item id="opted-item" class="${cls}"><ion-label>Opted out</ion-label></ion-item>
</ion-list>
<ion-list class="${cls}"><ion-item id="opted-list-item"><ion-label>Opted out list</ion-label></ion-item></ion-list>
</ion-card-content></ion-card>
<ion-item id="baseline"><ion-label>Baseline</ion-label></ion-item>
</div>`,
);
}, optOut);
await expect(page.locator('#optout-fixture ion-item:not(.hydrated)')).toHaveCount(0);
const padding = (id: string) => page.locator(`#${id} [part="native"]`).evaluate((el) => getComputedStyle(el).paddingInlineStart);
const baseline = await padding('baseline');
expect(await padding('themed')).not.toBe(baseline);
expect(await padding('opted-item')).toBe(baseline);
expect(await padding('opted-list-item')).toBe(baseline);
});

test(`${optOut} isolates stacked list layout and typography from themed siblings`, async ({ page }) => {
await page.goto('/main/index/item-list');
await expect(page.locator('app-item-list ion-list').first()).toHaveClass(/hydrated/);
await page.evaluate((cls) => {
document.querySelector('ion-app')!.insertAdjacentHTML(
'beforeend',
`
<div id="optout-fixture">
<ion-list inset="true">
<ion-list-header><ion-label id="themed-header">Themed</ion-label></ion-list-header>
<ion-list-header class="${cls}"><ion-label id="opted-header">Opted out</ion-label></ion-list-header>
<ion-item-group>
<ion-item id="themed"><ion-label>Label</ion-label><ion-note>Note</ion-note></ion-item>
<ion-item id="opted" class="${cls}"><ion-label>Label</ion-label><ion-note>Note</ion-note></ion-item>
</ion-item-group>
<ion-note id="themed-note">Themed</ion-note><ion-note id="opted-note" class="${cls}">Opted out</ion-note>
</ion-list>
<ion-list class="${cls}">
<ion-list-header><ion-label id="baseline-header">Baseline</ion-label></ion-list-header>
<ion-item id="baseline"><ion-label>Label</ion-label><ion-note>Note</ion-note></ion-item>
<ion-note id="baseline-note">Baseline</ion-note>
</ion-list>
</div>`,
);
}, optOut);
await expect(page.locator('#optout-fixture :is(ion-item,ion-label,ion-note):not(.hydrated)')).toHaveCount(0);
const styles = (id: string) =>
page.locator(`#${id}`).evaluate((el) => {
const style = getComputedStyle(el);
return { fontSize: style.fontSize, display: style.display };
});
const direction = (id: string) => page.locator(`#${id} [part="container"]`).evaluate((el) => getComputedStyle(el).flexDirection);
expect(await direction('themed')).toBe('column');
expect(await direction('opted')).toBe(await direction('baseline'));
expect(await direction('opted')).not.toBe('column');
for (const suffix of ['header', 'note']) {
expect(await styles(`opted-${suffix}`)).toEqual(await styles(`baseline-${suffix}`));
expect(await styles(`themed-${suffix}`)).not.toEqual(await styles(`baseline-${suffix}`));
}
});
}
103 changes: 103 additions & 0 deletions demo/e2e/ios26-navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { expect, test } from '@playwright/test';

test.use({ viewport: { width: 402, height: 874 } });

for (const [length, scrollTop] of [
['short', 0],
['long', 0],
['long', 24],
['long', 100],
] as const) {
test(`push preserves title geometry and pop restores scroll (${length}, ${scrollTop})`, async ({ page }) => {
await page.goto('/main/index');
const source = page.locator('index-page');
const content = source.locator('ion-content');
const title = content.locator('ion-title.title-large');
await expect(title).toBeAttached();
await content.evaluate(
async (el, { length, scrollTop }) => {
if (length === 'short') {
el.querySelectorAll('ion-item').forEach((item) => {
if (item.textContent?.trim() !== 'button') item.remove();
});
el.querySelectorAll('ion-list').forEach((list) => {
if (!list.querySelector('ion-item')) list.remove();
});
}
await (el as HTMLIonContentElement).scrollToPoint(0, scrollTop, 0);
},
{ length, scrollTop },
);
await expect
.poll(() => content.evaluate(async (el) => (await (el as HTMLIonContentElement).getScrollElement()).scrollTop))
.toBe(scrollTop);
const before = (await title.boundingBox())!;
// Pause the actual routed transition, rather than testing a copy of its keyframes.
await source.evaluate((el) => {
const content = el.querySelector('ion-content')!;
el.addEventListener(
'ionViewWillLeave',
() => {
const hold = () => {
const animations = document.getAnimations();
if (!animations.some((animation) => (animation.effect as KeyframeEffect).target === el)) {
requestAnimationFrame(hold);
return;
}
animations.forEach((animation) => {
animation.pause();
animation.currentTime = Number(animation.effect!.getTiming().duration) * 0.15;
});
content.dataset['motionHeld'] = 'true';
};
requestAnimationFrame(hold);
},
{ once: true },
);
});
await source
.locator('ion-item')
.filter({ has: page.getByText('button', { exact: true }) })
.click();
await expect(content).toHaveAttribute('data-motion-held', 'true');
const during = (await title.boundingBox())!;
expect(during.x).toBeLessThan(before.x);
expect(during.y).toBeCloseTo(before.y, 1);
expect(during.height).toBeCloseTo(before.height, 1);
await expect(title).toHaveCSS('opacity', '1');
await expect(source).toHaveCSS('opacity', '1');
await expect(page.locator('ion-title.ion-cloned-element')).toBeHidden();
const shade = page.locator('.ios-transition-shade');
const shadeBounds = (await shade.boundingBox())!;
const topBounds = (await page.locator('app-button').boundingBox())!;
expect(shadeBounds.y).toBeCloseTo(topBounds.y, 1);
expect(shadeBounds.height).toBeCloseTo(topBounds.height, 1);
expect(shadeBounds.x).toBe(0);
const edgeBounds = await shade.evaluate((el) => el.nextElementSibling!.getBoundingClientRect().toJSON());
expect(edgeBounds.x + edgeBounds.width).toBeCloseTo(topBounds.x, 1);
const dimming = await shade.evaluate((el) => Number(getComputedStyle(el).opacity));
expect(dimming).toBeGreaterThan(0);
expect(dimming).toBeLessThan(1);
for (const view of [source, page.locator('app-button')]) {
const header = view.locator(':scope > ion-header');
expect((await header.boundingBox())!.x).toBeCloseTo((await view.locator(':scope > ion-content').boundingBox())!.x, 1);
expect(await header.evaluate((el) => getComputedStyle(el, '::after').content)).not.toBe('none');
await expect(view.locator(':scope > ion-content .transition-effect')).toBeHidden();
}
await page.evaluate(() => document.getAnimations().forEach((animation) => animation.play()));
await expect(source).toHaveClass(/ion-page-hidden/);
await expect(shade).toHaveCount(0);
expect(await page.locator('app-button').evaluate((el) => (el as HTMLElement).style.clipPath)).toBe('');
await expect(page.locator('ion-back-button.ion-cloned-element')).toBeHidden();
await page.locator('app-button > ion-header ion-back-button').click();
await expect(source).not.toHaveClass(/ion-page-hidden/);
await expect.poll(async () => (await title.boundingBox())!.x).toBeCloseTo(before.x, 1);
await expect
.poll(() => content.evaluate(async (el) => (await (el as HTMLIonContentElement).getScrollElement()).scrollTop))
.toBe(scrollTop);
expect((await title.boundingBox())!.y).toBeCloseTo(before.y, 1);
await expect(shade).toHaveCount(0);
await expect(page.locator('ion-back-button.ion-cloned-element')).toBeHidden();
expect(await source.evaluate((el) => (el as HTMLElement).style.boxShadow)).toBe('');
});
}
Loading
Loading