mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-20 22:02:20 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd4e0e9bb |
@@ -2690,11 +2690,14 @@ body.theme-frosted .modal {
|
|||||||
max-height: 95dvh !important;
|
max-height: 95dvh !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Notes and Documents must respect the same viewport and safe-area
|
||||||
|
top as other mobile full-height sheets. Keep these authoritative rules
|
||||||
|
consistent with 05-documents.css and 14-email.css. */
|
||||||
body.doc-view .doc-editor-pane,
|
body.doc-view .doc-editor-pane,
|
||||||
body.notes-view .notes-pane {
|
body.notes-view .notes-pane {
|
||||||
inset: auto 0 0 !important;
|
inset: var(--sheet-top) 0 0 !important;
|
||||||
height: 95dvh !important;
|
height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||||
max-height: 95dvh !important;
|
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,6 @@
|
|||||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
<script nonce="{{CSP_NONCE}}">
|
<script nonce="{{CSP_NONCE}}">
|
||||||
window._odysseusLoadTime = Date.now();
|
window._odysseusLoadTime = Date.now();
|
||||||
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
|
|
||||||
(function(){
|
|
||||||
var ua = navigator.userAgent || '';
|
|
||||||
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
|
|
||||||
var vp = document.querySelector('meta[name="viewport"]');
|
|
||||||
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
|
|
||||||
})();
|
|
||||||
(function(){
|
(function(){
|
||||||
try {
|
try {
|
||||||
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
||||||
|
|||||||
@@ -379,7 +379,6 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
|||||||
root.classList.toggle('keyboard-open', keyboardOpen);
|
root.classList.toggle('keyboard-open', keyboardOpen);
|
||||||
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
|
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
|
||||||
else root.style.removeProperty('--keyboard-viewport-height');
|
else root.style.removeProperty('--keyboard-viewport-height');
|
||||||
if (window.scrollY) window.scrollTo(0, 0);
|
|
||||||
};
|
};
|
||||||
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
|
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
|
||||||
// the tapped field already had focus (e.g. the composer after a new chat)
|
// the tapped field already had focus (e.g. the composer after a new chat)
|
||||||
@@ -392,7 +391,6 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
|
|||||||
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
|
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
|
||||||
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
|
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
|
||||||
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
|
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
|
||||||
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
|
|
||||||
|
|
||||||
let _lastVVHeight = window.visualViewport.height;
|
let _lastVVHeight = window.visualViewport.height;
|
||||||
window.visualViewport.addEventListener('resize', function() {
|
window.visualViewport.addEventListener('resize', function() {
|
||||||
|
|||||||
@@ -267,8 +267,9 @@ export function makeWindowResizable(content, options = {}) {
|
|||||||
content.style.maxWidth = 'none';
|
content.style.maxWidth = 'none';
|
||||||
content.style.maxHeight = 'none';
|
content.style.maxHeight = 'none';
|
||||||
const left = parseFloat(content.style.left);
|
const left = parseFloat(content.style.left);
|
||||||
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
|
if (Number.isFinite(left)) {
|
||||||
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
|
const rightmostLeft = Math.max(padLeft, window.innerWidth - padRight - w);
|
||||||
|
content.style.left = Math.max(padLeft, Math.min(left, rightmostLeft)) + 'px';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Tests against the running Odysseus app, not a static mock page.
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
|
const repositoryRoot = path.resolve(__dirname, '../..');
|
||||||
|
const port = Number(process.env.ODYSSEUS_LAYOUT_PORT || '17739');
|
||||||
|
const baseURL = `http://127.0.0.1:${port}`;
|
||||||
|
const pythonInVenv = path.join(repositoryRoot, '.venv', 'bin', 'python');
|
||||||
|
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(pythonInVenv) ? pythonInVenv : 'python3');
|
||||||
|
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-pr6396-'));
|
||||||
|
const evidenceDir = process.env.ODYSSEUS_LAYOUT_EVIDENCE_DIR || path.join(os.homedir(), 'Pictures', 'Odysseus-6396-followup');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: path.join(__dirname, 'ios-layout'),
|
||||||
|
testMatch: ['**/*.spec.cjs'],
|
||||||
|
outputDir: path.join(dataDirectory, 'playwright-output'),
|
||||||
|
timeout: 90_000,
|
||||||
|
expect: { timeout: 15_000 },
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['line']],
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
headless: true,
|
||||||
|
serviceWorkers: 'block',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'desktop-chromium', use: { browserName: 'chromium', viewport: { width: 1440, height: 900 } } },
|
||||||
|
{ name: 'phone-webkit-portrait', use: { browserName: 'webkit', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true } },
|
||||||
|
{ name: 'phone-webkit-landscape', use: { browserName: 'webkit', viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true } },
|
||||||
|
{ name: 'tablet-webkit', use: { browserName: 'webkit', viewport: { width: 820, height: 1000 }, hasTouch: true, isMobile: true } },
|
||||||
|
],
|
||||||
|
metadata: { evidenceDir },
|
||||||
|
webServer: {
|
||||||
|
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
url: baseURL,
|
||||||
|
timeout: 120_000,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
AUTH_ENABLED: 'false',
|
||||||
|
// Never connect the disposable app to the host's real ChromaDB.
|
||||||
|
CHROMADB_HOST: '127.0.0.1',
|
||||||
|
CHROMADB_PORT: String(port + 1),
|
||||||
|
LLM_HOST: '127.0.0.1',
|
||||||
|
EMBEDDING_URL: `http://127.0.0.1:${port + 1}/v1/embeddings`,
|
||||||
|
DATABASE_URL: `sqlite:///${path.join(dataDirectory, 'app.db')}`,
|
||||||
|
ODYSSEUS_DATA_DIR: dataDirectory,
|
||||||
|
ODYSSEUS_STARTUP_WARMUPS: '0',
|
||||||
|
RESPONSE_COMPRESSION_ENABLED: 'false',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
async function openApp(page) {
|
||||||
|
const response = await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
expect(response?.ok()).toBeTruthy();
|
||||||
|
// The runtime currently produces two #message nodes. Select the
|
||||||
|
// initialized composer while diagnosing whether both are visible.
|
||||||
|
const composers = await page.locator('textarea#message').evaluateAll(nodes =>
|
||||||
|
nodes.map((el, index) => ({
|
||||||
|
index,
|
||||||
|
initialized: el.dataset.startupPreserveBound === '1',
|
||||||
|
visible: !!el.getClientRects().length &&
|
||||||
|
getComputedStyle(el).visibility !== 'hidden',
|
||||||
|
parent: el.parentElement?.className || '',
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
if (composers.length !== 1) {
|
||||||
|
console.log('Composer DOM diagnostic:', JSON.stringify(composers));
|
||||||
|
}
|
||||||
|
const activeComposer = page.locator(
|
||||||
|
'textarea#message[data-startup-preserve-bound="1"]'
|
||||||
|
);
|
||||||
|
await expect(activeComposer).toHaveCount(1);
|
||||||
|
await expect(activeComposer).toBeVisible();
|
||||||
|
expect(
|
||||||
|
composers.filter(composer => composer.visible).length,
|
||||||
|
'Exactly one composer should be visible'
|
||||||
|
).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renders actual Odysseus UI and captures review screenshots', async ({ page }, testInfo) => {
|
||||||
|
await openApp(page);
|
||||||
|
const out = testInfo.config.metadata.evidenceDir;
|
||||||
|
fs.mkdirSync(out, { recursive: true });
|
||||||
|
const name = testInfo.project.name;
|
||||||
|
await page.screenshot({ path: path.join(out, `${name}-chat.png`), fullPage: false, animations: 'disabled' });
|
||||||
|
|
||||||
|
// Screenshot the actual Settings UI only if a real user-visible Settings
|
||||||
|
// control is reachable; never fabricate a modal for PR screenshots.
|
||||||
|
const settingsButton = page.locator('#rail-settings:visible, #user-bar-settings:visible').first();
|
||||||
|
if (await settingsButton.count()) {
|
||||||
|
await settingsButton.click();
|
||||||
|
const dialog = page.locator('#settings-modal');
|
||||||
|
if (await dialog.isVisible()) {
|
||||||
|
await page.screenshot({ path: path.join(out, `${name}-settings.png`), fullPage: false, animations: 'disabled' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the real Notes route via an existing UI action when it is reachable.
|
||||||
|
// These screenshots reflect genuine runtime content, not test-injected panes.
|
||||||
|
await openApp(page);
|
||||||
|
const notesButton = page.locator('#rail-notes:visible, #tool-notes-btn:visible').first();
|
||||||
|
if (await notesButton.count()) {
|
||||||
|
await notesButton.click();
|
||||||
|
const realNotes = page.locator('.notes-pane:visible').first();
|
||||||
|
if (await realNotes.count()) {
|
||||||
|
await page.screenshot({ path: path.join(out, `${name}-notes.png`), fullPage: false, animations: 'disabled' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
testInfo.annotations.push({ type: 'evidence', description: out });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewport meta does not restrict user zoom', async ({ page }) => {
|
||||||
|
await openApp(page);
|
||||||
|
const meta = await page.locator('meta[name="viewport"]').getAttribute('content');
|
||||||
|
expect(meta || '').not.toMatch(/maximum-scale\s*=\s*1(?:\b|\.)/i);
|
||||||
|
expect(meta || '').not.toMatch(/user-scalable\s*=\s*no/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('browser-owned window scrolling is not globally reset', async ({ page }) => {
|
||||||
|
await openApp(page);
|
||||||
|
const value = await page.evaluate(async () => {
|
||||||
|
document.documentElement.style.overflowY = 'auto';
|
||||||
|
document.body.style.overflowY = 'auto';
|
||||||
|
document.body.style.height = 'auto';
|
||||||
|
document.body.style.minHeight = '3200px';
|
||||||
|
const spacer = document.createElement('div');
|
||||||
|
spacer.style.cssText = 'position:absolute;top:3100px;width:1px;height:10px;pointer-events:none;';
|
||||||
|
document.body.appendChild(spacer);
|
||||||
|
window.scrollTo(0, 250);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
return window.scrollY;
|
||||||
|
});
|
||||||
|
expect(value).toBeGreaterThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile Notes and Documents panes honor the safe-area top', async ({ page }, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'phone-webkit-portrait', 'Only mobile <=768px');
|
||||||
|
await openApp(page);
|
||||||
|
const results = await page.evaluate(() => {
|
||||||
|
document.documentElement.style.setProperty('--safe-top', '44px');
|
||||||
|
document.documentElement.style.setProperty('--sheet-top', '68px');
|
||||||
|
document.documentElement.style.setProperty('--app-height', '844px');
|
||||||
|
const values = {};
|
||||||
|
for (const [key, bodyClass, paneClass] of [
|
||||||
|
['notes', 'notes-view', 'notes-pane'],
|
||||||
|
['document', 'doc-view', 'doc-editor-pane'],
|
||||||
|
]) {
|
||||||
|
const pane = document.createElement('section');
|
||||||
|
pane.className = paneClass;
|
||||||
|
pane.style.opacity = '0'; // CSS-only fixture, not a screenshot of fake app content
|
||||||
|
document.body.appendChild(pane);
|
||||||
|
document.body.classList.add(bodyClass);
|
||||||
|
const style = window.getComputedStyle(pane);
|
||||||
|
values[key] = {
|
||||||
|
top: style.top,
|
||||||
|
height: style.height,
|
||||||
|
insetTop: pane.getBoundingClientRect().top,
|
||||||
|
};
|
||||||
|
pane.remove();
|
||||||
|
document.body.classList.remove(bodyClass);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
});
|
||||||
|
for (const key of ['notes', 'document']) {
|
||||||
|
expect(results[key].top, key).toBe('68px');
|
||||||
|
expect(parseFloat(results[key].height), key).toBeGreaterThanOrEqual(770);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restored floating windows clamp away from a left safe area', async ({ page }, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'Geometry test uses desktop resizable windows');
|
||||||
|
await openApp(page);
|
||||||
|
const value = await page.evaluate(async () => {
|
||||||
|
document.documentElement.style.setProperty('--safe-left', '58px');
|
||||||
|
document.body.style.setProperty('padding-left', '58px', 'important');
|
||||||
|
const probe = document.createElement('div');
|
||||||
|
probe.style.cssText = 'position:fixed;left:10px;top:80px;width:420px;height:250px;z-index:999999;';
|
||||||
|
document.body.appendChild(probe);
|
||||||
|
const key = 'odysseus-layout-restore-test';
|
||||||
|
localStorage.setItem(key, JSON.stringify({ w: 480, h: 280 }));
|
||||||
|
try {
|
||||||
|
// Body horizontal padding transitions over 160ms. Wait for the
|
||||||
|
// simulated safe-area change to settle before restoring geometry.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
const padAtRestore = parseFloat(
|
||||||
|
getComputedStyle(document.body).paddingLeft
|
||||||
|
);
|
||||||
|
console.log('PADDING AT RESTORE:', padAtRestore);
|
||||||
|
|
||||||
|
if (Math.abs(padAtRestore - 58) > 0.5) {
|
||||||
|
throw new Error(
|
||||||
|
`Safe-area fixture did not settle: ${padAtRestore}px`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the running server actually serves the patched module.
|
||||||
|
const response = await fetch(
|
||||||
|
'/static/js/windowResize.js',
|
||||||
|
{ cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Cannot fetch windowResize.js');
|
||||||
|
}
|
||||||
|
const moduleSource = await response.text();
|
||||||
|
if (!moduleSource.includes('const rightmostLeft = Math.max(padLeft')) {
|
||||||
|
throw new Error('Running server is serving unpatched windowResize.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { makeWindowResizable } = await import('/static/js/windowResize.js');
|
||||||
|
makeWindowResizable(probe, { storageKey: key, mobileSkip: 768 });
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
return {
|
||||||
|
left: parseFloat(probe.style.left),
|
||||||
|
width: parseFloat(probe.style.width),
|
||||||
|
paddingLeft: parseFloat(getComputedStyle(document.body).paddingLeft),
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
probe.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('WINDOW RESTORE DIAGNOSTIC:', JSON.stringify(value));
|
||||||
|
expect(value.paddingLeft).toBeGreaterThanOrEqual(58);
|
||||||
|
expect(value.width).toBe(480);
|
||||||
|
expect(value.left).toBeGreaterThanOrEqual(value.paddingLeft);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user