diff --git a/.github/workflows/deploy-timeweb.yml b/.github/workflows/deploy-timeweb.yml
index 3767346..98b6d23 100644
--- a/.github/workflows/deploy-timeweb.yml
+++ b/.github/workflows/deploy-timeweb.yml
@@ -50,6 +50,7 @@ jobs:
core/catalog-pricing.js
core/client-menu.css
core/ops-ux-v1762.js
+ core/mobile-order.js
core/banquet-menu.js
core/data-layer-v1773.js
core/trial-demo.js
@@ -112,6 +113,9 @@ jobs:
- name: Check the published loading screen, Help, clients and promotions
timeout-minutes: 4
run: node tests/production-ui-smoke.mjs
+ - name: Check the published mobile menu form and return arrow
+ timeout-minutes: 4
+ run: node tests/production-mobile-menu.mjs
- name: Save production UI verification
if: always()
uses: actions/upload-artifact@v4
diff --git a/public/core/mobile-order.js b/public/core/mobile-order.js
index 76a879d..a131b15 100644
--- a/public/core/mobile-order.js
+++ b/public/core/mobile-order.js
@@ -35,5 +35,79 @@
const observer=new MutationObserver(update);observer.observe(view,{attributes:true,attributeFilter:['class']});observer.observe(document.body,{attributes:true,attributeFilter:['class']});
layout();
}
- if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
+ // The menu page is created later by ops-ux. Attach once when it exists, then
+ // observe only its visibility and direct detail children, never the whole app.
+ function bootMenu(){
+ const view=document.getElementById('sun-menu-editor-v1762'),layout=view?.querySelector('.sun-menu-layout');
+ const list=layout?.querySelector(':scope > .sun-menu-list-pane'),detail=document.getElementById('sunMenuDetailV1762');
+ if(!view||!layout||!list||!detail)return false;
+ if(view.dataset.ctMobileMenu==='1')return true;
+ view.dataset.ctMobileMenu='1';
+ const mobile=matchMedia('(max-width:760px)');
+ const button=document.createElement('button');button.id='ctMenuTop';button.type='button';button.hidden=true;
+ button.setAttribute('aria-label','Наверх к редактору меню');button.title='Наверх к редактору меню';
+ button.setAttribute('aria-controls','sunMenuDetailV1762');
+ button.innerHTML='Наверх';
+ document.body.appendChild(button);
+ const style=document.createElement('style');style.id='ctMobileMenuStyle';style.textContent=`
+ #ctMenuTop{display:none}
+ @media(max-width:760px){
+ #sun-menu-editor-v1762 .sun-menu-layout{grid-template-columns:minmax(0,1fr)}
+ #sun-menu-editor-v1762 #sunMenuDetailV1762{grid-column:1;grid-row:1;min-width:0;min-height:0}
+ #sun-menu-editor-v1762 .sun-menu-list-pane{grid-column:1;grid-row:2;min-width:0;min-height:0}
+ #sun-menu-editor-v1762 .sun-menu-list{max-height:none;overflow:visible}
+ #sun-menu-editor-v1762 .sun-menu-detail-placeholder{min-height:76px}
+ #ctMenuTop:not([hidden]){display:flex;align-items:center;justify-content:center;gap:6px;position:fixed;right:16px;bottom:calc(88px + env(safe-area-inset-bottom,0px));z-index:900;min-width:48px;min-height:48px;padding:10px 14px;border:1px solid #d7bb65;border-radius:24px;background:#f6e7af;color:#302919;box-shadow:0 4px 18px #0002;font:700 13px/1.2 Arial,sans-serif;cursor:pointer}
+ #ctMenuTop:focus-visible{outline:3px solid #546747;outline-offset:3px}
+ }
+ @media print{#ctMenuTop{display:none!important}}
+ `;document.head.appendChild(style);
+ function active(){return mobile.matches&&view.classList.contains('on')&&!document.body.classList.contains('sun-cloud-auth-required');}
+ function visibility(){button.hidden=!active()||document.body.classList.contains('sun-modal-open')||window.scrollY<300;}
+ function neutralHint(){
+ const label=detail.querySelector('.sun-menu-detail-placeholder b');
+ if(label?.textContent==='Выберите позицию слева, чтобы открыть карточку.')label.textContent='Выберите позицию из списка, чтобы открыть карточку.';
+ }
+ function arrange(){
+ // Move the LIST, not the form: an unsaved or focused editor keeps its node,
+ // input values, file selection and listeners across viewport changes.
+ if(mobile.matches){if(detail.nextElementSibling!==list)detail.after(list);}
+ else if(list.nextElementSibling!==detail)layout.insertBefore(list,detail);
+ neutralHint();visibility();
+ }
+ function returnToEditor(){
+ if(!active())return;
+ window.scrollTo({top:0,left:0,behavior:'instant'});
+ detail.setAttribute('tabindex','-1');detail.focus({preventScroll:true});visibility();
+ }
+ let frame=0;
+ function update(){if(frame)return;frame=requestAnimationFrame(()=>{frame=0;neutralHint();visibility();});}
+ button.addEventListener('click',returnToEditor);
+ view.addEventListener('click',e=>{
+ const target=e.target.closest?.('[data-menu-item-v1762],#sunMenuAddV1762');
+ if(!target||target.disabled||!active())return;
+ // The existing click handler docks the editor in a zero-delay task. Wait
+ // for it, and do not scroll if the user has left Menu in the meantime.
+ setTimeout(()=>requestAnimationFrame(()=>{
+ // Never take focus or scroll away after the user has started editing.
+ const focused=document.activeElement;
+ if(detail.contains(focused)&&focused?.matches('input,textarea,select,[contenteditable="true"]'))return;
+ if(detail.querySelector(':scope > .dialog,:scope > .sun-menu-readonly'))returnToEditor();
+ }),0);
+ });
+ window.addEventListener('scroll',update,{passive:true});mobile.addEventListener('change',arrange);
+ const observer=new MutationObserver(update);
+ observer.observe(view,{attributes:true,attributeFilter:['class']});
+ observer.observe(document.body,{attributes:true,attributeFilter:['class']});
+ observer.observe(detail,{childList:true});
+ arrange();return true;
+ }
+ function start(){
+ boot();
+ if(!bootMenu()){
+ const waiting=new MutationObserver(()=>{if(bootMenu())waiting.disconnect();});
+ waiting.observe(document.body,{childList:true});
+ }
+ }
+ if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',start,{once:true});else start();
})();
diff --git a/tests/mobile-menu-scenario.mjs b/tests/mobile-menu-scenario.mjs
new file mode 100644
index 0000000..22de839
--- /dev/null
+++ b/tests/mobile-menu-scenario.mjs
@@ -0,0 +1,84 @@
+import assert from 'node:assert/strict';
+import {expect} from '@playwright/test';
+
+// The real application assets run with a synthetic local workspace. No backend
+// traffic or real customer credentials are allowed, including on production.
+export async function bootMenuFixture(page,baseUrl='http://127.0.0.1:4173/'){
+ const base=new URL(baseUrl);
+ await page.route('**/*',route=>{
+ const req=route.request(),url=new URL(req.url());
+ if(req.method()==='GET'&&url.origin===base.origin&&!url.pathname.startsWith('/api/'))return route.continue();
+ return route.abort();
+ });
+ await page.addInitScript(()=>{
+ const id='mobile-menu-workspace',user={id:'mobile-menu-user',email:'menu-test@example.invalid',email_confirmed_at:'2026-01-01T00:00:00Z'};
+ localStorage.setItem('sunCloudV2Config',JSON.stringify({workspaceId:id,localWorkspaceId:id,tenantStorageReady:true,autoSync:false}));
+ localStorage.setItem('sunBoxes',JSON.stringify(Array.from({length:24},(_,i)=>({id:`mobile-box-${i+1}`,name:`Бокс ${String(i+1).padStart(2,'0')}`,category:0,price:1000+i*10,weight:'600 г',pieces:12,ingredients:[['Томаты',1,'кг']]}))));
+ localStorage.setItem('sunOrders',JSON.stringify([{id:1,event:'Тестовый заказ',date:'2099-12-20',time:'12:00',status:'Новый',lines:[{id:'mobile-box-1',qty:2,price:1000}],total:2000}]));
+ const features=Object.fromEntries('orders calendar clients catalog_view catalog_edit production shopping stock routes mailings money stats_basic stats_advanced team suppliers print settings branding client_offers offer_templates backups audit users_manage'.split(' ').map(k=>[k,true]));
+ window.supabase={createClient:()=>({
+ auth:{onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),getSession:async()=>({data:{session:{user}},error:null}),getUser:async()=>({data:{user},error:null})},
+ rpc:async name=>({data:name==='sun_my_workspaces'?[{id,name:'Тестовая компания',role:'admin',is_active:true,permissions:{}}]:name==='sun_subscription_snapshot'?{plan_id:'full',plan_name:'Полный',status:'active',access_mode:'full',features}:name==='sun_is_platform_admin'?false:name==='caterium_trial_demo_status'?{canInstall:false,canUpgrade:false}:null,error:null}),
+ channel:()=>({on(){return this},subscribe(){return this}}),removeChannel(){}
+ })};
+ });
+ await page.goto(base.href,{waitUntil:'domcontentloaded',timeout:60000});
+ await expect(page.locator('body > header')).toBeVisible({timeout:20000});
+ await page.waitForFunction(()=>window.SunOpsUXV1762&&window.CateriumDataV1773&&window.__cateriumOrderEnhancementsV1775);
+ await page.locator('header nav').getByRole('button',{name:'Меню',exact:true}).click();
+ await expect(page.locator('#sun-menu-editor-v1762')).toHaveAttribute('data-ct-mobile-menu','1');
+ await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(24);
+}
+
+export async function menuGeometry(page){
+ let geometry;
+ // Read rectangles and media queries atomically. WebKit/mobile Chromium can
+ // update the layout viewport a frame after a resize or keyboard transition.
+ await expect.poll(async()=>{
+ geometry=await page.evaluate(()=>{
+ const d=document.getElementById('sunMenuDetailV1762'),l=document.querySelector('.sun-menu-list-pane');
+ return {detail:d.getBoundingClientRect().toJSON(),list:l.getBoundingClientRect().toJSON(),mobile:matchMedia('(max-width:760px)').matches,singleColumn:matchMedia('(max-width:950px)').matches,formFirst:d.nextElementSibling===l,listFirst:l.nextElementSibling===d,viewport:innerWidth,scrollWidth:document.documentElement.scrollWidth};
+ });
+ const {detail:d,list:l,mobile,singleColumn,formFirst,listFirst}=geometry;
+ return mobile?(formFirst&&d.bottom<=l.y+1):(listFirst&&(singleColumn?l.bottom<=d.y+1:l.right<=d.x+1));
+ },{message:'Menu layout and reading order must match the current breakpoint'}).toBe(true);
+ if(geometry.mobile)assert(geometry.scrollWidth<=geometry.viewport+1,'No horizontal overflow on phones');
+ return geometry;
+}
+
+export async function exerciseMenuNavigation(page,{screenshot=async()=>{}}={}){
+ const geometry=await menuGeometry(page),top=page.locator('#ctMenuTop');
+ await expect(top).toBeHidden();
+ const before=await page.evaluate(()=>({orders:localStorage.sunOrders,boxes:localStorage.sunBoxes}));
+ await page.locator('[data-menu-item-v1762="mobile-box-24"]').click();
+ await expect(page.locator('#sunMenuDetailV1762 > .dialog #boxName')).toHaveValue('Бокс 24');
+ if(geometry.mobile)await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
+ await menuGeometry(page);
+ await screenshot('menu-form-top');
+ await page.locator('#boxName').fill('Несохранённый бокс');
+ await page.locator('#boxPrice').fill('1350');
+ await page.evaluate(()=>{window.mobileEditorNode=document.getElementById('boxName');window.mobileEditorInputEvents=0;mobileEditorNode.addEventListener('input',()=>window.mobileEditorInputEvents++);});
+ await page.locator('[data-menu-item-v1762="mobile-box-24"]').scrollIntoViewIfNeeded();
+ if(geometry.mobile){
+ await expect(top).toBeVisible();
+ await expect(page.locator('#ctOrderTop')).toBeHidden();
+ const bounds=await top.boundingBox();assert(bounds.width>=48&&bounds.height>=48);
+ await screenshot('menu-arrow');
+ await top.click();
+ await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
+ await expect(page.locator('#sunMenuDetailV1762')).toBeFocused();
+ await expect(top).toBeHidden();
+ }else await expect(top).toBeHidden();
+ await expect(page.locator('#boxName')).toHaveValue('Несохранённый бокс');
+ await expect(page.locator('#boxPrice')).toHaveValue('1350');
+ assert(await page.evaluate(()=>window.mobileEditorNode===document.getElementById('boxName')),'The form must not be recreated');
+ assert.deepEqual(await page.evaluate(()=>({orders:localStorage.sunOrders,boxes:localStorage.sunBoxes})),before,'Navigation must not save or change data');
+ await page.locator('#saveBoxButton').click();
+ await expect.poll(()=>page.evaluate(()=>JSON.parse(localStorage.sunBoxes).find(x=>x.id==='mobile-box-24')?.name)).toBe('Несохранённый бокс');
+ assert.equal(await page.evaluate(()=>JSON.parse(localStorage.sunBoxes).find(x=>x.id==='mobile-box-24').price),1350);
+ assert.equal(await page.evaluate(()=>localStorage.sunOrders),before.orders);
+ await page.locator('header nav').getByRole('button',{name:'Заказы',exact:true}).click();
+ await expect(top).toBeHidden();
+ await expect(page.locator('#editor > .dialog')).toHaveCount(1);
+ return {...geometry,navigationPreservesDraft:true,saveWorks:true,ordersUnchanged:true};
+}
diff --git a/tests/mobile-menu.spec.mjs b/tests/mobile-menu.spec.mjs
new file mode 100644
index 0000000..a75d907
--- /dev/null
+++ b/tests/mobile-menu.spec.mjs
@@ -0,0 +1,81 @@
+import {test,expect} from '@playwright/test';
+import {bootMenuFixture,menuGeometry,exerciseMenuNavigation} from './mobile-menu-scenario.mjs';
+
+test.use({serviceWorkers:'block'});
+
+test('mobile menu puts editing above boxes and returns without losing unsaved values',async({page})=>{
+ await bootMenuFixture(page);await exerciseMenuNavigation(page);
+});
+
+test('mobile menu viewport changes move the list, not the focused editor',async({page})=>{
+ await bootMenuFixture(page);
+ await page.locator('[data-menu-item-v1762="mobile-box-1"]').click();
+ await expect(page.locator('#sunMenuDetailV1762 > .dialog')).toBeVisible();
+ // The automatic return is phone-only; desktop keeps its existing scroll.
+ if(await page.evaluate(()=>matchMedia('(max-width:760px)').matches))await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
+ await page.waitForTimeout(80);
+ await page.locator('#boxName').fill('Сохрани ввод при повороте');
+ await page.locator('#boxName').focus();
+ await page.evaluate(()=>{window.formInput=document.getElementById('boxName');window.inputsSeen=0;formInput.addEventListener('input',()=>window.inputsSeen++);});
+ for(const width of [1440,390,760,1440,390]){
+ await page.setViewportSize({width,height:900});
+ await expect.poll(()=>page.evaluate(()=>{const d=document.getElementById('sunMenuDetailV1762'),list=document.querySelector('.sun-menu-list-pane');return matchMedia('(max-width:760px)').matches?d.nextElementSibling===list:list.nextElementSibling===d;})).toBe(true);
+ await menuGeometry(page);
+ await expect(page.locator('#boxName')).toHaveValue('Сохрани ввод при повороте');
+ await expect(page.locator('#boxName')).toBeFocused();
+ expect(await page.evaluate(()=>formInput===document.getElementById('boxName'))).toBe(true);
+ }
+ await page.locator('#boxName').fill('Обработчик остался');
+ expect(await page.evaluate(()=>inputsSeen)).toBeGreaterThan(0);
+ await expect(page.locator('#ctMenuTop')).toHaveCount(1);
+});
+
+test('mobile menu search, category and new item keep working; leaving prevents a delayed scroll',async({page})=>{
+ await bootMenuFixture(page);
+ await page.locator('#sunMenuSearchV1762').fill('Бокс 07');
+ await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(1);
+ await page.locator('[data-menu-cat-v1762="5"]').click();
+ await page.locator('#sunMenuAddV1762').click();
+ await expect(page.locator('#sunMenuDetailV1762 > .dialog')).toBeVisible();
+ await expect(page.locator('#itemCategory')).toHaveValue('5');
+ if(await page.evaluate(()=>matchMedia('(max-width:760px)').matches))await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
+ await menuGeometry(page);
+ await page.locator('#boxName').fill('Премиум с телефона');await page.locator('#boxPrice').fill('2200');await page.locator('#saveBoxButton').click();
+ await expect.poll(()=>page.evaluate(()=>JSON.parse(localStorage.sunBoxes).find(x=>x.name==='Премиум с телефона')?.category)).toBe(5);
+ await page.evaluate(()=>{document.getElementById('sunMenuAddV1762').click();[...document.querySelectorAll('header nav button')].find(b=>b.textContent.trim()==='Заказы').click();});
+ await page.waitForTimeout(100);
+ await expect(page.locator('#editor > .dialog')).toHaveCount(1);
+ await expect(page.locator('#ctMenuTop')).toBeHidden();
+ await page.locator('header nav').getByRole('button',{name:'Меню',exact:true}).click();
+ await expect(page.locator('#ctMenuTop')).toHaveCount(1);
+});
+
+test('read-only mobile menu opens the card above the list without enabling editing',async({page})=>{
+ await bootMenuFixture(page);
+ const before=await page.evaluate(()=>localStorage.sunBoxes);
+ await page.evaluate(()=>{SunCloudV2.hasPermission=p=>p==='catalog.view'||p==='app.read';window.dispatchEvent(new Event('sun:cloud-permissions-changed'));});
+ await expect(page.locator('#sunMenuAddV1762')).toBeDisabled();
+ await page.locator('[data-menu-item-v1762="mobile-box-24"]').click();
+ await expect(page.locator('#sunMenuDetailV1762 .sun-menu-readonly')).toContainText('Бокс 24');
+ if(await page.evaluate(()=>matchMedia('(max-width:760px)').matches))await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
+ await menuGeometry(page);
+ await expect(page.locator('#sunMenuDetailV1762 > .dialog')).toHaveCount(0);
+ expect(await page.evaluate(()=>localStorage.sunBoxes)).toBe(before);
+});
+
+
+test('a queued mobile return does not steal focus once the user starts typing',async({page})=>{
+ await bootMenuFixture(page);
+ await page.evaluate(()=>{
+ document.querySelector('[data-menu-item-v1762="mobile-box-1"]').click();
+ // Docking is queued by the existing menu handler. Start typing in the next
+ // task, before the mobile requestAnimationFrame navigation callback runs.
+ setTimeout(()=>{
+ const input=document.getElementById('boxName');input.value='Уже редактирую';input.focus({preventScroll:true});
+ },0);
+ });
+ await expect(page.locator('#sunMenuDetailV1762 > .dialog #boxName')).toHaveValue('Уже редактирую');
+ await page.waitForTimeout(150);
+ await expect(page.locator('#boxName')).toBeFocused();
+ await expect(page.locator('#boxName')).toHaveValue('Уже редактирую');
+});
diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs
index 216620c..5f93f3c 100644
--- a/tests/playwright.config.mjs
+++ b/tests/playwright.config.mjs
@@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test';
import {fileURLToPath} from 'node:url';
export default defineConfig({
testDir:'.',
- testMatch:['client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'],
+ testMatch:['mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'],
timeout:30000,
use:{baseURL:'http://127.0.0.1:4173'},
webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},
projects:[
{name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}},
- {name:'iphone-webkit',testMatch:['client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}},
+ {name:'iphone-webkit',testMatch:['mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}},
{name:'desktop',use:{...devices['Desktop Chrome']}},
{name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}}
]
diff --git a/tests/production-mobile-menu.mjs b/tests/production-mobile-menu.mjs
new file mode 100644
index 0000000..76ecf24
--- /dev/null
+++ b/tests/production-mobile-menu.mjs
@@ -0,0 +1,19 @@
+import fs from 'node:fs/promises';
+import {chromium} from '@playwright/test';
+import {bootMenuFixture,exerciseMenuNavigation} from './mobile-menu-scenario.mjs';
+const base=new URL(process.env.TIMEWEB_BASE_URL||'https://app.caterium.ru');
+if(base.protocol!=='https:')throw new Error('Production UI verification requires HTTPS');
+const output='production-ui-results';await fs.mkdir(output,{recursive:true});
+const browser=await chromium.launch(),results=[];
+try{
+ for(const width of [390,1440]){
+ const context=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'});
+ try{
+ const page=await context.newPage();
+ await bootMenuFixture(page,base.href);
+ const result=await exerciseMenuNavigation(page,{screenshot:async name=>{await page.screenshot({path:`${output}/${name}-${width}.png`,animations:'disabled'});}});
+ results.push({width,...result});console.log(`PASS published menu ${width}px: form placement, arrow, unsaved values, save, unchanged orders`);
+ }finally{await context.close();}
+ }
+ await fs.writeFile(`${output}/mobile-menu-result.json`,JSON.stringify({base:base.href,checkedAt:new Date().toISOString(),backend:'synthetic, network-blocked',results},null,2));
+}finally{await browser.close();}