diff --git a/test_android_back.mjs b/test_android_back.mjs index 14c5e65..a159dc8 100644 --- a/test_android_back.mjs +++ b/test_android_back.mjs @@ -238,6 +238,27 @@ try { check('closing a dialog does not exit', appearance.exits === 0, JSON.stringify(appearance)); + const finalWorkbookId = await page.evaluate(() => + window.notebookManager.getActiveNotebook()?.id); + await page.click('#menu-btn'); + await page.click('#btn-close-notebook'); + await page.waitForSelector('#close-final-notebook-modal:not(.hidden)'); + await page.waitForFunction(() => document.activeElement?.id + === 'close-final-notebook-cancel'); + await press(page); + await page.waitForFunction(() => document.getElementById( + 'close-final-notebook-modal').classList.contains('hidden')); + const finalWorkbookBack = await page.evaluate((id) => ({ + kept: Boolean(window.notebookManager.getNotebook(id)), + focus: document.activeElement?.id, + exits: window.__appPluginMock.exitCalls, + }), finalWorkbookId); + check('Back cancels the final-workbook warning through its real X action', + finalWorkbookBack.kept && finalWorkbookBack.focus === 'btn-close-notebook', + JSON.stringify(finalWorkbookBack)); + check('cancelling the final-workbook warning does not exit', + finalWorkbookBack.exits === 0, JSON.stringify(finalWorkbookBack)); + // Same-z-index dialogs paint in DOM order. Only the visually topmost one // should close on a press. await page.evaluate(() => { diff --git a/test_appearance.mjs b/test_appearance.mjs index 60aaad8..47e81cb 100644 --- a/test_appearance.mjs +++ b/test_appearance.mjs @@ -132,6 +132,83 @@ try { await page.evaluate(() => getComputedStyle(document.documentElement) .getPropertyValue('--accent').trim()) === '#58a6ff'); + /* ---------------------- essential text contrast ------------------- */ + console.log('\n3a. Essential control and label contrast'); + + const contrast = []; + for (const theme of ['dark', 'light']) { + contrast.push(await page.evaluate((selectedTheme) => { + window.appearance.setTheme(selectedTheme); + + const resolveColor = (value) => { + const probe = document.createElement('span'); + probe.style.color = value; + document.body.appendChild(probe); + const resolved = getComputedStyle(probe).color; + probe.remove(); + return resolved; + }; + const components = (value) => { + const values = resolveColor(value).match(/[\d.]+/g)?.slice(0, 3).map(Number) || []; + return values.map((component) => component / 255); + }; + const luminance = (value) => { + const [r, g, b] = components(value).map((component) => component <= 0.04045 + ? component / 12.92 : ((component + 0.055) / 1.055) ** 2.4); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const ratio = (foreground, background) => { + const a = luminance(foreground); + const b = luminance(background); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + }; + const output = document.createElement('section'); + output.className = 'card card-output'; + output.innerHTML = '
Output
'; + document.getElementById('repl').appendChild(output); + const outputLabel = output.querySelector('.card-label'); + const outputStyle = getComputedStyle(outputLabel); + const outputRatio = ratio(outputStyle.color, getComputedStyle(output).backgroundColor); + + window.onboarding.start(); + const progressStyle = getComputedStyle(document.getElementById('tour-progress')); + const tourCardBackground = getComputedStyle(document.getElementById('tour-card')).backgroundColor; + const nextStyle = getComputedStyle(document.getElementById('tour-next')); + const progressRatio = ratio(progressStyle.color, tourCardBackground); + const nextRatio = ratio(nextStyle.color, nextStyle.backgroundColor); + + const run = document.getElementById('run-btn'); + run.disabled = false; + const runStyle = getComputedStyle(run); + const runColor = runStyle.color; + // Inspect what the button actually paints. Reading the custom + // properties instead would be vacuous: the test could stay green + // if #run-btn stopped using them. + const runEndpoints = [...runStyle.backgroundImage.matchAll(/rgba?\([^)]+\)/g)] + .map((match) => match[0]); + const runRatios = runEndpoints.map((endpoint) => ratio(runColor, endpoint)); + + window.onboarding.finish(); + output.remove(); + run.disabled = true; + return { + theme: selectedTheme, + output: outputRatio, + progress: progressRatio, + next: nextRatio, + runEndpoints: runEndpoints.length, + runStart: runRatios[0] || 0, + runEnd: runRatios[1] || 0, + }; + }, theme)); + } + check('output labels, Tour progress/Next, and Run meet 4.5:1 in dark and light themes', + contrast.every((theme) => theme.runEndpoints >= 2 + && ['output', 'progress', 'next', 'runStart', 'runEnd'] + .every((key) => theme[key] >= 4.5)), JSON.stringify(contrast)); + + await page.evaluate(() => window.appearance.setTheme('dark')); + /* ------------------- dark is the product default -------------------- */ console.log('\n3b. Dark is the default, on a light-mode device'); diff --git a/test_i18n_dynamic.mjs b/test_i18n_dynamic.mjs index 5fd8a21..c7924c9 100644 --- a/test_i18n_dynamic.mjs +++ b/test_i18n_dynamic.mjs @@ -155,6 +155,28 @@ try { check('generated default notebook name is localized', state.notebook === state.notebookWant && state.notebook !== 'Notebook 1', JSON.stringify(state)); check('generated code placeholder is localized', state.placeholder === state.placeholderWant && !state.placeholder.startsWith('Type '), JSON.stringify(state)); check('generated ready status is localized', state.ready === state.readyWant && state.ready !== 'ready', JSON.stringify(state)); + state = await page.evaluate(() => { + const keys = { + 'search-btn': 'notebookSelector.findNotebookTitle', + 'tour-shortcut-btn': 'header.takeTourTitle', + 'menu-btn': 'menu.title', + 'help-btn': 'tour.help.title', + }; + return Object.entries(keys).map(([id, key]) => { + const button = document.getElementById(id); + return { + id, + key, + name: button?.getAttribute('aria-label'), + title: button?.title, + want: window.t(key), + tagged: button?.getAttribute('data-i18n-aria-label') === key, + }; + }); + }); + check('header glyph buttons expose localized names and retain their i18n bindings', + state.every((item) => item.tagged && item.name === item.want + && item.title === item.want && item.name.length > 1), JSON.stringify(state)); state = await page.evaluate(() => ({ label: document.getElementById('rename-cell-input')?.labels?.[0]?.textContent?.trim(), want: window.t('renameCell.enterNameCellEmptyClear'), @@ -201,6 +223,15 @@ try { // Generated nodes retain their key/variables, so changing locale while the // panel is open must update it in place rather than requiring a close/reopen. await activate('fr'); + state = await page.evaluate(() => ({ + search: document.getElementById('search-btn')?.getAttribute('aria-label'), + searchWant: window.t('notebookSelector.findNotebookTitle'), + menu: document.getElementById('menu-btn')?.getAttribute('aria-label'), + menuWant: window.t('menu.title'), + })); + check('header accessible names resynchronize after a programmatic locale change', + state.search === state.searchWant && state.menu === state.menuWant + && state.search !== 'Find in notebook', JSON.stringify(state)); state = await page.evaluate(() => ({ description: document.querySelector('[data-catalog-key="unifyweaver-scirepl"] .pkg-description')?.textContent, want: window.t('packageCatalog.item.unifyweaverScirepl.description'), diff --git a/test_onboarding.mjs b/test_onboarding.mjs index 71d2058..1c0fe44 100644 --- a/test_onboarding.mjs +++ b/test_onboarding.mjs @@ -43,6 +43,20 @@ try { check('it opens on the display-language step', await page.evaluate(() => !!document.getElementById('tour-language-select'))); + const languageName = await page.evaluate(() => { + const select = document.getElementById('tour-language-select'); + const label = select?.labels?.[0]; + return { + labelFor: label?.htmlFor, + label: label?.textContent, + want: window.t('whatsNew.displayLanguage'), + }; + }); + check('the Tour display-language picker has a real translated label', + languageName.labelFor === 'tour-language-select' + && languageName.label === languageName.want, + JSON.stringify(languageName)); + // The distinction that makes this tour worth having. const titles = await page.evaluate(() => window.onboarding.steps.map((s) => window.t(s.titleKey))); @@ -106,6 +120,13 @@ try { await page.waitForTimeout(500); check('changing language re-renders the tour in that language', (await titleNow()) === 'Elige el idioma de la interfaz', await titleNow()); + check('the Tour language picker label re-renders in the selected locale', + await page.evaluate(() => { + const select = document.getElementById('tour-language-select'); + const label = select?.labels?.[0]; + return label?.textContent === window.t('whatsNew.displayLanguage') + && label.textContent !== 'Display language'; + })); await page.evaluate(async () => { const es = window.i18n.LOCALES.find((l) => l.code === 'es'); if (es) { es.status = 'draft'; es.completeness = 0.17; } diff --git a/test_pip_cell.mjs b/test_pip_cell.mjs index af616d7..f1bda71 100644 --- a/test_pip_cell.mjs +++ b/test_pip_cell.mjs @@ -8,10 +8,11 @@ // xyzservices etc. from the real lock and make bokeh.embed/io/plotting work. import { chromium } from 'playwright'; import { execFileSync } from 'node:child_process'; -import { mkdirSync, rmSync } from 'node:fs'; +import { mkdirSync, readFileSync, rmSync } from 'node:fs'; const TIMEOUT = 180_000; -const BASE = 'http://localhost:8085'; +const BASE = (process.env.SCIREPL_TEST_BASE || 'http://localhost:8085').replace(/\/+$/, ''); +const APP_VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version; const FIXDIR = new URL('./www/test_fixtures/wheels/', import.meta.url).pathname; const RUN_LIVE = process.env.RUN_LIVE_CDN === '1'; @@ -87,15 +88,16 @@ const FIXTURE_LOCK = { try { console.log('1. Navigating to SciREPL...'); const context = browser.contexts()[0]; - await context.addInitScript(() => { + await context.addInitScript((appVersion) => { localStorage.setItem('scirepl_privacy_accepted', '1'); localStorage.setItem('scirepl_onboarding_seen', '1'); localStorage.setItem('scirepl_auto_download', '1'); - addEventListener('DOMContentLoaded', () => localStorage.setItem( - 'scirepl_whats_new_seen_version', window.KERNEL_CONFIG.app.version), { once: true }); - }); + localStorage.setItem('scirepl_whats_new_seen_version', appVersion); + }, APP_VERSION); await page.goto(BASE + '/', { waitUntil: 'domcontentloaded', timeout: TIMEOUT }); console.log(' Waiting for Pyodide...'); + await page.waitForFunction(() => window.kernelManager + && typeof window.kernelManager.ensureReady === 'function', null, { timeout: TIMEOUT }); await page.evaluate(() => { window.kernelManager.ensureReady('python'); }); await page.waitForFunction(() => { const km = window.kernelManager; diff --git a/test_whats_new.mjs b/test_whats_new.mjs index f6b7667..da9d5e3 100644 --- a/test_whats_new.mjs +++ b/test_whats_new.mjs @@ -92,19 +92,25 @@ try { 'whatsNew.highlightPipPackages', 'whatsNew.highlightLanguageAndHeader', 'whatsNew.highlightAndroidAndWindows', - ].join(',') && document.querySelector('[data-i18n="whatsNew.intro"]')?.textContent.includes('1.2.0'); + ].join(','); })); - check('1.3.2 preserves that summary and adds the Android file-safety fixes', + check('1.3.2 contains only its focused closing, import, and accessibility delta', await page.evaluate(() => { const keys = window.SCIREPL_RELEASE_HIGHLIGHTS['1.3.2'] || []; return keys.join(',') === [ - 'whatsNew.highlightCatalogBrowse', - 'whatsNew.highlightFormulaContexts', - 'whatsNew.highlightPipPackages', - 'whatsNew.highlightLanguageAndHeader', - 'whatsNew.highlightAndroidFilesAndWindows', + 'whatsNew.highlightWorkbookClosing', + 'whatsNew.highlightAndroidCloudImport', + 'whatsNew.highlightAccessibility', ].join(',') && document.querySelector('[data-i18n="whatsNew.intro"]') - ?.textContent.includes('1.2.0'); + ?.textContent === window.t('whatsNew.intro'); + })); + check('1.3.2 explains durable closing, the resettable warning, and cloud-file import', + await page.evaluate(() => { + const closing = window.t('whatsNew.highlightWorkbookClosing'); + const importing = window.t('whatsNew.highlightAndroidCloudImport'); + return /stay closed after a restart/i.test(closing) + && /restore.*in Settings/i.test(closing) + && /generic binary/i.test(importing); })); check('the release link remains valid before a tag exists', /\/releases\/?$/.test(release.href), release.href); check('focus moves into the modal', release.focusInside); diff --git a/test_workbook_close_import_picker.mjs b/test_workbook_close_import_picker.mjs index 197c65b..c8667e6 100644 --- a/test_workbook_close_import_picker.mjs +++ b/test_workbook_close_import_picker.mjs @@ -243,6 +243,67 @@ const APP_URL = process.env.SCIREPL_TEST_BASE || 'http://localhost:8085/'; warning.body.includes(warning.name) && /export/i.test(warning.body) && warning.nativeConfirms === 0 && warning.settingDefault === null, JSON.stringify(warning)); + + await page.waitForFunction(() => document.activeElement?.id + === 'close-final-notebook-cancel'); + check('the warning moves focus to its safe Cancel action', + await page.evaluate(() => document.activeElement?.id + === 'close-final-notebook-cancel')); + + await page.focus('#close-final-notebook-x'); + await page.keyboard.press('Shift+Tab'); + const reverseWrappedTo = await page.evaluate(() => document.activeElement?.id); + await page.keyboard.press('Tab'); + const forwardWrappedTo = await page.evaluate(() => document.activeElement?.id); + check('Tab and Shift+Tab stay inside the warning', + reverseWrappedTo === 'close-final-notebook-confirm' + && forwardWrappedTo === 'close-final-notebook-x', + JSON.stringify({ reverseWrappedTo, forwardWrappedTo })); + + await page.evaluate(() => { + window.__closeWarningEscapes = 0; + document.addEventListener('keydown', event => { + if (event.key === 'Escape') window.__closeWarningEscapes++; + }); + }); + await page.focus('#close-final-notebook-cancel'); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => document.getElementById( + 'close-final-notebook-modal')?.classList.contains('hidden')); + const escaped = await page.evaluate(oldId => ({ + bubbles: window.__closeWarningEscapes, + focus: document.activeElement?.id, + stillOpen: Boolean(window.notebookManager.getNotebook(oldId)) + }), seeded.id); + check('Escape cancels only the warning and restores its opener', + escaped.bubbles === 0 && escaped.focus === 'btn-close-notebook' + && escaped.stillOpen, + JSON.stringify(escaped)); + + // Open and close in the same frame. A stale queued focus callback must + // not move focus back into the now-hidden modal. + await page.evaluate(() => { + const opener = document.getElementById('btn-close-notebook'); + opener.focus(); + opener.click(); + document.getElementById('close-final-notebook-x').click(); + }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame( + () => requestAnimationFrame(resolve)))); + const immediateCancel = await page.evaluate(oldId => ({ + hidden: document.getElementById('close-final-notebook-modal') + .classList.contains('hidden'), + focus: document.activeElement?.id, + stillOpen: Boolean(window.notebookManager.getNotebook(oldId)) + }), seeded.id); + check('closing before the focus frame cancels that queued work', + immediateCancel.hidden && immediateCancel.focus === 'btn-close-notebook' + && immediateCancel.stillOpen, + JSON.stringify(immediateCancel)); + + await page.waitForFunction(() => !document.getElementById('btn-close-notebook')?.disabled); + await page.click('#btn-close-notebook'); + await page.waitForSelector('#close-final-notebook-modal:not(.hidden)'); const backResult = await page.evaluate(() => { const back = window.SciReplAndroidBack; return (back?.dismissTopmostUi || back?.dismissTopmost)?.call(back); @@ -251,7 +312,8 @@ const APP_URL = process.env.SCIREPL_TEST_BASE || 'http://localhost:8085/'; 'close-final-notebook-modal')?.classList.contains('hidden')); check('Android Back cancels the explanation and leaves the workbook open', backResult === 'modal' - && await page.evaluate(oldId => !!window.notebookManager.getNotebook(oldId), seeded.id), + && await page.evaluate(oldId => !!window.notebookManager.getNotebook(oldId) + && document.activeElement?.id === 'btn-close-notebook', seeded.id), String(backResult)); await page.waitForFunction(() => !document.getElementById('btn-close-notebook')?.disabled); @@ -426,7 +488,152 @@ const APP_URL = process.env.SCIREPL_TEST_BASE || 'http://localhost:8085/'; && multiple.originalKept && multiple.secondGone && multiple.secondDiskGone, JSON.stringify(multiple)); - console.log('6. Failed destructive flushes retain deletion tombstones'); + console.log('6. Every visible notebook × uses the durable close path'); + // The existing mandatory-control collision below roughly 405px is a + // separate selector-layout follow-up. Exercise each close control at a + // width where its full hit target is actually available. + await page.setViewportSize({ width: 1024, height: 800 }); + const exerciseSelectorClose = async (mode, selector, name) => { + const prepared = await page.evaluate(async ({ mode, name }) => { + const nm = window.notebookManager; + document.getElementById('menu-modal')?.classList.add('hidden'); + if (nm.getNotebooks().length !== 1) { + throw new Error(`Expected one keeper notebook, found ${nm.getNotebooks().length}`); + } + const target = nm.createNotebook({ name }); + nm.switchTo(target.id); + window._cells = [{ + id: 1, + code: `${name}_SENTINEL`, + type: 'markdown', + language: 'markdown', + name: '', + inputCard: null, + outputCard: null + }]; + window._cellCounter = 1; + nm.setUIMode(mode); + nm.saveState(); + await window.sessionManager.saveSharedState(); + const beforeDisk = await window.vfsStore.loadSharedFiles(); + const oldPath = beforeDisk.find(file => file.path + .startsWith('/shared/notebooks/') + && file.path.includes(name))?.path || ''; + + const prototypeClose = Object.getPrototypeOf(nm).closeNotebook; + window.__selectorClose = { calls: [], settled: null }; + nm.closeNotebook = async function (id) { + window.__selectorClose.calls.push(id); + const result = await prototypeClose.call(this, id); + window.__selectorClose.settled = id; + return result; + }; + window.confirm = () => true; + return { id: target.id, oldPath }; + }, { mode, name }); + + await page.locator(selector).click(); + await page.waitForFunction(id => window.__selectorClose?.settled === id, + prepared.id); + const closedFromSource = await page.evaluate(async ({ id, oldPath }) => { + const diskFiles = await window.vfsStore.loadSharedFiles(); + return { + calls: [...window.__selectorClose.calls], + gone: !window.notebookManager.getNotebook(id), + diskGone: !diskFiles.some(file => file.path === oldPath) + }; + }, prepared); + + const restart = await context.newPage(); + await restart.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: TIMEOUT }); + await ready(restart); + const afterRestart = await restart.evaluate(({ id, oldPath }) => { + let names = []; + try { names = window.sharedVFS.listDir('/shared/notebooks'); } catch (_) { } + return window.vfsStore.loadSharedFiles().then(files => ({ + notebookGone: !window.notebookManager.getNotebook(id), + memoryGone: !names.some(name => oldPath.endsWith('/' + name)), + diskGone: !files.some(file => file.path === oldPath) + })); + }, prepared); + await restart.close(); + return { prepared, closedFromSource, afterRestart }; + }; + + for (const testCase of [{ + mode: 'dropdown', + selector: '#notebook-selector-container .notebook-add-btn:nth-of-type(2)', + name: 'CLOSE_DROPDOWN' + }, { + mode: 'sidebar', + selector: '#sidebar-notebook-list .sidebar-notebook-item.active .sidebar-nb-close', + name: 'CLOSE_SIDEBAR' + }, { + mode: 'tabs', + selector: '#notebook-selector-container .notebook-tab.active .tab-close', + name: 'CLOSE_TABS' + }]) { + const route = await exerciseSelectorClose( + testCase.mode, testCase.selector, testCase.name); + check(`${testCase.mode} × awaits closeNotebook and its disk flush`, + route.prepared.oldPath + && route.closedFromSource.calls.join('|') === route.prepared.id + && route.closedFromSource.gone && route.closedFromSource.diskGone, + JSON.stringify(route)); + check(`${testCase.mode} × cannot resurrect its workbook after restart`, + route.afterRestart.notebookGone && route.afterRestart.memoryGone + && route.afterRestart.diskGone, + JSON.stringify(route)); + } + + const importRollback = await page.evaluate(async () => { + const nm = window.notebookManager; + const fio = window.fileIO; + const app = window._appInternals; + const beforeIds = nm.getNotebooks().map(notebook => notebook.id); + const originalCreateInputCard = app.createInputCard; + const prototypeClose = Object.getPrototypeOf(nm).closeNotebook; + let closeCalls = 0; + nm.closeNotebook = async function (id) { + closeCalls++; + return prototypeClose.call(this, id); + }; + app.createInputCard = () => { + throw new Error('forced create-import render failure'); + }; + let error = ''; + try { + await fio.importWorkbook(JSON.stringify({ + format: 'srwb', + format_version: '1.0', + notebook: { + name: 'TRANSIENT_IMPORT', + cells: [{ code: 'never committed', type: 'markdown' }] + } + }), { format: 'srwb', mode: 'create' }); + } catch (caught) { + error = String(caught?.message || caught); + } finally { + app.createInputCard = originalCreateInputCard; + } + return { + error, + closeCalls, + beforeIds, + afterIds: nm.getNotebooks().map(notebook => notebook.id), + transientGone: !nm.getNotebooks().some(notebook => + notebook.name === 'TRANSIENT_IMPORT') + }; + }); + check('failed create-import rollback remains internal and non-destructive', + /forced create-import render failure/.test(importRollback.error) + && importRollback.closeCalls === 0 + && importRollback.transientGone + && JSON.stringify(importRollback.afterIds) + === JSON.stringify(importRollback.beforeIds), + JSON.stringify(importRollback)); + + console.log('7. Failed destructive flushes retain deletion tombstones'); const exerciseFailedFlush = async (sourcePage, name, failureMode) => { const prepared = await sourcePage.evaluate(async ({ name, failureMode }) => { const nm = window.notebookManager; diff --git a/www/css/style.css b/www/css/style.css index 3c991b0..a624048 100644 --- a/www/css/style.css +++ b/www/css/style.css @@ -11,6 +11,10 @@ --text-muted: #484f58; --accent: #58a6ff; --accent-glow: rgba(88, 166, 255, 0.15); + /* Dedicated action colours keep white button text at WCAG AA contrast + without darkening links, focus rings, or other uses of --accent. */ + --primary-action: #1f6feb; + --primary-action-end: #8250df; --green: #3fb950; --orange: #d29922; --red: #f85149; @@ -370,7 +374,7 @@ html, body { gap: 6px; padding: 6px 12px; font-size: 11px; - color: var(--text-muted); + color: var(--text-secondary); font-family: var(--font-mono); border-bottom: 1px solid var(--border); } @@ -506,7 +510,7 @@ html, body { padding: 10px 20px; border: none; border-radius: 8px; - background: linear-gradient(135deg, var(--accent), #a371f7); + background: linear-gradient(135deg, var(--primary-action), var(--primary-action-end)); color: #fff; font-weight: 700; font-size: 14px; @@ -3115,6 +3119,8 @@ body.force-mobile .vfs-delete-btn { --text-muted: #818b98; --accent: #0969da; --accent-glow: rgba(9, 105, 218, 0.12); + --primary-action: #0969da; + --primary-action-end: #8250df; --green: #1a7f37; --orange: #9a6700; --red: #cf222e; @@ -3307,6 +3313,13 @@ body.force-mobile .vfs-delete-btn { width: 100%; } +.tour-language > label { + display: block; + margin-bottom: 6px; + color: var(--text-secondary); + font-size: 13px; +} + .tour-language-shortcut { /* Stacked, not a row: sharing one line left the select ~119px while the French value needs ~139px, so the chosen mode was clipped on a 320px @@ -3449,7 +3462,7 @@ body.force-mobile .vfs-delete-btn { } #tour-progress { - color: var(--text-muted); + color: var(--text-secondary); font-size: 12px; font-variant-numeric: tabular-nums; } @@ -3471,8 +3484,8 @@ body.force-mobile .vfs-delete-btn { } .tour-btn { - background: var(--accent); - border-color: var(--accent); + background: var(--primary-action); + border-color: var(--primary-action); color: #fff; font-weight: 600; } diff --git a/www/i18n/ar.json b/www/i18n/ar.json index ab80a5c..b3dd7e8 100644 --- a/www/i18n/ar.json +++ b/www/i18n/ar.json @@ -132,7 +132,7 @@ "whatsNew.version": "الإصدار {version}", "whatsNew.unreleasedVersion": "تغييرات غير منشورة · الإصدار الأساسي {version}", "whatsNew.title": "ما الجديد في SciREPL", - "whatsNew.intro": "أبرز التحسينات المضافة منذ SciREPL 1.2.0:", + "whatsNew.intro": "أبرز ما في هذا التحديث:", "whatsNew.highlightLanguages": "تساعد الواجهة المترجمة والجولة الإرشادية عند التشغيل الأول مزيدًا من الأشخاص على البدء.", "whatsNew.highlightShortcuts": "اختر ما إذا كانت اختصارات الجولة والصيغ ستظهر في شريط العنوان.", "whatsNew.highlightOffline": "تمنع الترقيات المتسقة مع الإصدار في وضع عدم الاتصال خلط ملفات من إصدارات مختلفة.", @@ -146,7 +146,9 @@ "whatsNew.highlightPipPackages": "يمكن لأمر %pip install جلب حزم Pyodide المتوافقة والتبعيات المفقودة من شبكة CDN الرسمية، ويحترم متطلبات الإصدار ويدعم الرسم في الخلية نفسها.", "whatsNew.highlightLanguageAndHeader": "يحتفظ إدخال لغة البرمجة المحدد الآن باسمه الكامل في القائمة (بما في ذلك Py - Python)، بينما يبقى عنصر التحكم المغلق مختصرًا؛ ويمكن ضبط اختصارات «تصفح» و«الصيغ» و«الجولة» على دائمًا أو عند توفر مساحة أو أبدًا.", "whatsNew.highlightAndroidAndWindows": "يغلق زر الرجوع في Android الآن لوحة المفاتيح أو شاشة SciREPL العليا قبل التنقل أو الخروج، وتتضمن الإصدارات ذات العلامات أيضًا نسخة Windows محمولة.", - "whatsNew.highlightAndroidFilesAndWindows": "يغلق زر الرجوع في Android الآن لوحة المفاتيح أو شاشة SciREPL العليا أولًا؛ وتقبل عمليات استيراد المصنفات من موفري التخزين السحابي الملفات الثنائية العامة؛ ويعرض إغلاق المصنف الأخير إرشادات التصدير ويفتح مصنفًا بديلًا جديدًا وفارغًا؛ كما تتضمن الإصدارات ذات العلامات نسخة Windows محمولة.", + "whatsNew.highlightWorkbookClosing": "تبقى المصنفات المغلقة الآن مغلقة بعد إعادة تشغيل التطبيق. ويعرض إغلاق المصنف الأخير تذكيرًا بالتصدير، ويمكن إعادة هذا التذكير من الإعدادات.", + "whatsNew.highlightAndroidCloudImport": "يمكن لتطبيق Android الآن استيراد ملفات المصنفات التي يصنّفها موفرو التخزين السحابي كملفات ثنائية عامة.", + "whatsNew.highlightAccessibility": "تُحسّن الأسماء الأوضح لقارئات الشاشة والتباين الأقوى شريط الرأس والجولة وتسميات المخرجات وزر التشغيل.", "whatsNew.releaseNotes": "قراءة ملاحظات الإصدار الكاملة", "menu.showTour": "🧭 بدء الجولة الإرشادية", "searchReplaceRow.replace": "استبدال", diff --git a/www/i18n/bn.json b/www/i18n/bn.json index 642b0b3..8d19c0b 100644 --- a/www/i18n/bn.json +++ b/www/i18n/bn.json @@ -132,7 +132,7 @@ "whatsNew.version": "সংস্করণ {version}", "whatsNew.unreleasedVersion": "অপ্রকাশিত পরিবর্তন · ভিত্তি সংস্করণ {version}", "whatsNew.title": "SciREPL-এ নতুন কী", - "whatsNew.intro": "SciREPL 1.2.0-এর পর থেকে যোগ হওয়া প্রধান উন্নতিগুলো:", + "whatsNew.intro": "এই আপডেটের উল্লেখযোগ্য পরিবর্তনগুলো:", "whatsNew.highlightLanguages": "স্থানীয়কৃত ইন্টারফেস ও প্রথমবার চালুর ট্যুর আরও বেশি মানুষকে সহজে শুরু করতে সাহায্য করে।", "whatsNew.highlightShortcuts": "ট্যুর ও ফর্মুলা শর্টকাট হেডারে দেখানো হবে কি না তা বেছে নিন।", "whatsNew.highlightOffline": "সংস্করণ-সামঞ্জস্যপূর্ণ অফলাইন আপগ্রেড বিভিন্ন রিলিজের ফাইল মিশে যাওয়া রোধ করে।", @@ -146,7 +146,9 @@ "whatsNew.highlightPipPackages": "%pip install অফিসিয়াল CDN থেকে অনুপস্থিত সামঞ্জস্যপূর্ণ Pyodide প্যাকেজ ও নির্ভরতা আনতে পারে, সংস্করণের শর্ত মেনে চলে এবং একই সেলে প্লট তৈরি করতে দেয়।", "whatsNew.highlightLanguageAndHeader": "নির্বাচিত প্রোগ্রামিং-ভাষার নাম এখন তালিকায় সম্পূর্ণ থাকে (Py - Python-সহ), তবে বন্ধ নিয়ন্ত্রণটি সংক্ষিপ্ত থাকে; আর ব্রাউজ, ফর্মুলা ও ট্যুর শর্টকাট সর্বদা, যখন জায়গা থাকে বা কখনও নয় হিসেবে রাখা যায়।", "whatsNew.highlightAndroidAndWindows": "Android-এর ফিরে যাওয়ার বোতাম এখন নেভিগেট বা প্রস্থান করার আগে কীবোর্ড বা সবচেয়ে উপরের SciREPL পর্দা বন্ধ করে, এবং ট্যাগযুক্ত রিলিজে পোর্টেবল Windows বিল্ডও থাকে।", - "whatsNew.highlightAndroidFilesAndWindows": "Android-এর ফিরে যাওয়ার বোতাম এখন প্রথমে কীবোর্ড বা সবচেয়ে উপরের SciREPL পর্দা বন্ধ করে; ক্লাউড সেবা থেকে ওয়ার্কবুক ইমপোর্ট করার সময় সাধারণ বাইনারি ফাইলও গ্রহণ করা হয়; শেষ ওয়ার্কবুক বন্ধ করলে এক্সপোর্টের নির্দেশনা দেখিয়ে একটি নতুন ফাঁকা ওয়ার্কবুক খোলা হয়; ট্যাগযুক্ত রিলিজে পোর্টেবল Windows বিল্ডও থাকে।", + "whatsNew.highlightWorkbookClosing": "বন্ধ করা ওয়ার্কবুক এখন অ্যাপ পুনরায় চালু করার পরও বন্ধ থাকে। শেষ ওয়ার্কবুক বন্ধ করলে এক্সপোর্টের অনুস্মারক দেখায়, এবং সেটিংস থেকে সেই অনুস্মারক আবার চালু করা যায়।", + "whatsNew.highlightAndroidCloudImport": "Android এখন ক্লাউড সেবা সাধারণ বাইনারি ফাইল হিসেবে চিহ্নিত করা ওয়ার্কবুক ফাইলও ইমপোর্ট করতে পারে।", + "whatsNew.highlightAccessibility": "আরও স্পষ্ট স্ক্রিন-রিডার নাম এবং উন্নত কনট্রাস্ট হেডার, ট্যুর, আউটপুট লেবেল ও রান বোতামকে আরও সহজলভ্য করেছে।", "whatsNew.releaseNotes": "সম্পূর্ণ রিলিজ নোট পড়ুন", "menu.showTour": "🧭 ট্যুর শুরু করুন", "searchReplaceRow.replace": "প্রতিস্থাপন", diff --git a/www/i18n/de.json b/www/i18n/de.json index 92123d9..ae0f8b2 100644 --- a/www/i18n/de.json +++ b/www/i18n/de.json @@ -147,7 +147,7 @@ "whatsNew.version": "Version {version}", "whatsNew.unreleasedVersion": "Unveröffentlichte Änderungen · Basisversion {version}", "whatsNew.title": "Neu in SciREPL", - "whatsNew.intro": "Höhepunkte seit SciREPL 1.2.0:", + "whatsNew.intro": "Highlights dieses Updates:", "whatsNew.highlightLanguages": "Eine lokalisierte Oberfläche und eine Tour beim ersten Start erleichtern mehr Menschen den Einstieg.", "whatsNew.highlightShortcuts": "Legen Sie fest, ob die Tour- und Formel-Verknüpfungen in der Kopfzeile erscheinen.", "whatsNew.highlightOffline": "Versionskonsistente Offline-Aktualisierungen verhindern, dass Dateien verschiedener Versionen vermischt werden.", @@ -161,7 +161,9 @@ "whatsNew.highlightPipPackages": "%pip install kann fehlende kompatible Pyodide-Pakete und Abhängigkeiten vom offiziellen CDN abrufen, beachtet Versionsanforderungen und unterstützt Diagramme in derselben Zelle.", "whatsNew.highlightLanguageAndHeader": "Der ausgewählte Eintrag behält nun seinen vollständigen Namen in der Programmiersprachenliste (einschließlich Py - Python), während das geschlossene Steuerelement kompakt bleibt; Durchsuchen, Formel und Tour können auf Immer, Wenn Platz ist oder Nie gesetzt werden.", "whatsNew.highlightAndroidAndWindows": "Die Android-Zurück-Taste schließt nun zuerst die Tastatur oder den obersten SciREPL-Bildschirm, bevor navigiert oder die App beendet wird; markierte Releases enthalten außerdem einen portablen Windows-Build.", - "whatsNew.highlightAndroidFilesAndWindows": "Die Android-Zurück-Taste schließt nun zuerst die Tastatur oder den obersten SciREPL-Bildschirm; Arbeitsmappen von Cloud-Anbietern lassen sich auch dann importieren, wenn sie als allgemeine Binärdateien gekennzeichnet sind; beim Schließen der letzten Arbeitsmappe erscheinen Exporthinweise und eine leere Ersatzarbeitsmappe wird geöffnet; markierte Releases enthalten außerdem einen portablen Windows-Build.", + "whatsNew.highlightWorkbookClosing": "Geschlossene Arbeitsmappen bleiben nun auch nach einem Neustart geschlossen. Beim Schließen der letzten Arbeitsmappe erscheint eine Exporterinnerung, die in den Einstellungen wieder aktiviert werden kann.", + "whatsNew.highlightAndroidCloudImport": "Android kann nun Arbeitsmappendateien importieren, die von Cloud-Anbietern als allgemeine Binärdateien gekennzeichnet sind.", + "whatsNew.highlightAccessibility": "Klarere Namen für Screenreader und ein stärkerer Kontrast verbessern Kopfzeile, Tour, Ausgabebeschriftungen und die Schaltfläche „Ausführen“.", "whatsNew.releaseNotes": "Vollständige Versionshinweise lesen", "menu.showTour": "🧭 Tour starten", "searchReplaceRow.replace": "Ersetzen", diff --git a/www/i18n/en.json b/www/i18n/en.json index 5bbdf8a..0a459db 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -375,7 +375,7 @@ "whatsNew.version": "Version {version}", "whatsNew.unreleasedVersion": "Unreleased changes · base version {version}", "whatsNew.title": "What’s new in SciREPL", - "whatsNew.intro": "Highlights added since SciREPL 1.2.0:", + "whatsNew.intro": "Highlights in this update:", "whatsNew.highlightLanguages": "A localized interface and first-run tour help more people get started.", "whatsNew.highlightShortcuts": "Choose whether the Tour and Formula shortcuts appear in the header.", "whatsNew.highlightOffline": "Version-coherent offline upgrades avoid mixing files from different releases.", @@ -389,7 +389,9 @@ "whatsNew.highlightPipPackages": "%pip install can fetch missing compatible Pyodide packages and dependencies from the official CDN, honours version requirements and supports plotting in the same cell.", "whatsNew.highlightLanguageAndHeader": "The selected programming-language entry now keeps its full name in the picker (including Py - Python) while the closed control stays compact; Browse, Formula and Tour shortcuts can be Always, When there is room or Never.", "whatsNew.highlightAndroidAndWindows": "Android Back now closes the keyboard or topmost SciREPL screen before navigating or exiting, and tagged releases include a portable Windows build.", - "whatsNew.highlightAndroidFilesAndWindows": "Android Back now closes the keyboard or topmost SciREPL screen first; cloud-provider workbook imports accept generic binary files; closing the final workbook gives export guidance and opens a clean replacement; tagged releases include a portable Windows build.", + "whatsNew.highlightWorkbookClosing": "Closed workbooks now stay closed after a restart. Closing the final workbook shows an export reminder, and you can restore that reminder in Settings.", + "whatsNew.highlightAndroidCloudImport": "Android can now import workbook files that cloud providers label as generic binary files.", + "whatsNew.highlightAccessibility": "Clearer screen-reader names and stronger contrast improve the header, Tour, output labels, and Run button.", "whatsNew.releaseNotes": "Read the full release notes", "menu.showTour": "🧭 Take the Tour", "searchReplaceRow.replace": "Replace", diff --git a/www/i18n/es.json b/www/i18n/es.json index e8e64e6..a5d5616 100644 --- a/www/i18n/es.json +++ b/www/i18n/es.json @@ -141,7 +141,7 @@ "whatsNew.version": "Versión {version}", "whatsNew.unreleasedVersion": "Cambios sin publicar · versión base {version}", "whatsNew.title": "Novedades de SciREPL", - "whatsNew.intro": "Aspectos destacados añadidos desde SciREPL 1.2.0:", + "whatsNew.intro": "Novedades destacadas de esta actualización:", "whatsNew.highlightLanguages": "Una interfaz localizada y un recorrido durante el primer inicio ayudan a más personas a comenzar.", "whatsNew.highlightShortcuts": "Elige si los accesos directos al recorrido y a Fórmulas aparecen en el encabezado.", "whatsNew.highlightOffline": "Las actualizaciones sin conexión coherentes con la versión evitan mezclar archivos de distintas versiones.", @@ -155,7 +155,9 @@ "whatsNew.highlightPipPackages": "%pip install puede obtener del CDN oficial los paquetes compatibles de Pyodide y las dependencias que falten, respeta las restricciones de versión y permite crear gráficos en la misma celda.", "whatsNew.highlightLanguageAndHeader": "La entrada de lenguaje seleccionada conserva ahora su nombre completo en la lista (incluido Py - Python), mientras el control cerrado sigue siendo compacto; los accesos directos Explorar, Fórmula y Recorrido pueden configurarse como Siempre, Cuando haya espacio o Nunca.", "whatsNew.highlightAndroidAndWindows": "El botón Atrás de Android ahora cierra el teclado o la pantalla superior de SciREPL antes de navegar o salir, y las versiones etiquetadas incluyen una compilación portátil para Windows.", - "whatsNew.highlightAndroidFilesAndWindows": "El botón Atrás de Android ahora cierra primero el teclado o la pantalla superior de SciREPL; las importaciones de libros de trabajo desde proveedores en la nube aceptan archivos binarios genéricos; al cerrar el último libro de trabajo se muestran indicaciones para exportarlo y se abre uno nuevo en blanco; las versiones etiquetadas incluyen una compilación portátil para Windows.", + "whatsNew.highlightWorkbookClosing": "Los libros de trabajo cerrados ahora permanecen cerrados tras reiniciar. Al cerrar el último libro aparece un recordatorio de exportación que puede reactivarse en Ajustes.", + "whatsNew.highlightAndroidCloudImport": "Android ahora puede importar archivos de libros de trabajo que los proveedores de nube etiquetan como binarios genéricos.", + "whatsNew.highlightAccessibility": "Los nombres más claros para lectores de pantalla y un mayor contraste mejoran el encabezado, el recorrido, las etiquetas de salida y el botón Ejecutar.", "whatsNew.releaseNotes": "Leer las notas de la versión completas", "menu.showTour": "🧭 Ver la guía", "searchReplaceRow.replace": "Reemplazar", diff --git a/www/i18n/fr.json b/www/i18n/fr.json index 5283d78..1ea3a1c 100644 --- a/www/i18n/fr.json +++ b/www/i18n/fr.json @@ -144,7 +144,7 @@ "whatsNew.version": "Version {version}", "whatsNew.unreleasedVersion": "Modifications non publiées · version de base {version}", "whatsNew.title": "Nouveautés de SciREPL", - "whatsNew.intro": "Points forts ajoutés depuis SciREPL 1.2.0 :", + "whatsNew.intro": "Points forts de cette mise à jour :", "whatsNew.highlightLanguages": "Une interface localisée et une visite guidée au premier démarrage aident davantage de personnes à se lancer.", "whatsNew.highlightShortcuts": "Choisissez si les raccourcis Visite guidée et Formule apparaissent dans l’en-tête.", "whatsNew.highlightOffline": "Les mises à niveau hors ligne cohérentes avec la version évitent de mélanger des fichiers provenant de versions différentes.", @@ -158,7 +158,9 @@ "whatsNew.highlightPipPackages": "%pip install peut récupérer les paquets Pyodide compatibles manquants et leurs dépendances depuis le CDN officiel, respecte les contraintes de version et permet de tracer un graphique dans la même cellule.", "whatsNew.highlightLanguageAndHeader": "L’entrée de langage sélectionnée conserve désormais son nom complet dans la liste (dont Py - Python), tandis que le contrôle fermé reste compact ; les raccourcis Parcourir, Formule et Visite guidée peuvent être Toujours, Quand il y a de la place ou Jamais.", "whatsNew.highlightAndroidAndWindows": "Le bouton Retour d’Android ferme désormais le clavier ou l’écran SciREPL au premier plan avant de naviguer ou de quitter, et les versions balisées incluent une version Windows portable.", - "whatsNew.highlightAndroidFilesAndWindows": "Le bouton Retour d’Android ferme désormais d’abord le clavier ou l’écran SciREPL au premier plan ; l’importation de classeurs depuis un fournisseur cloud accepte les fichiers binaires génériques ; la fermeture du dernier classeur affiche des conseils d’exportation et ouvre un classeur vierge de remplacement ; les versions balisées incluent une version Windows portable.", + "whatsNew.highlightWorkbookClosing": "Les classeurs fermés restent désormais fermés après un redémarrage. Fermer le dernier classeur affiche un rappel d’exportation, qui peut être réactivé dans les paramètres.", + "whatsNew.highlightAndroidCloudImport": "Android peut désormais importer les fichiers de classeur que les fournisseurs cloud marquent comme binaires génériques.", + "whatsNew.highlightAccessibility": "Des noms plus clairs pour les lecteurs d’écran et un contraste renforcé améliorent l’en-tête, la visite guidée, les libellés de sortie et le bouton Exécuter.", "whatsNew.releaseNotes": "Lire les notes de version complètes", "menu.showTour": "🧭 Faire la visite guidée", "searchReplaceRow.replace": "Remplacer", diff --git a/www/i18n/hi.json b/www/i18n/hi.json index 6b45f79..493cf36 100644 --- a/www/i18n/hi.json +++ b/www/i18n/hi.json @@ -132,7 +132,7 @@ "whatsNew.version": "संस्करण {version}", "whatsNew.unreleasedVersion": "अप्रकाशित बदलाव · आधार संस्करण {version}", "whatsNew.title": "SciREPL में नया क्या है", - "whatsNew.intro": "SciREPL 1.2.0 के बाद जोड़े गए प्रमुख सुधार:", + "whatsNew.intro": "इस अपडेट की मुख्य बातें:", "whatsNew.highlightLanguages": "स्थानीयकृत इंटरफ़ेस और पहली बार शुरू होने वाला टूर अधिक लोगों को आसानी से शुरुआत करने में मदद करता है।", "whatsNew.highlightShortcuts": "चुनें कि टूर और फ़ॉर्मूला शॉर्टकट हेडर में दिखाई दें या नहीं।", "whatsNew.highlightOffline": "संस्करण-संगत ऑफ़लाइन अपग्रेड अलग-अलग रिलीज़ की फ़ाइलों को मिलाने से रोकते हैं।", @@ -146,7 +146,9 @@ "whatsNew.highlightPipPackages": "%pip install आधिकारिक CDN से गुम संगत Pyodide पैकेज और निर्भरताएँ ला सकता है, संस्करण आवश्यकताओं का पालन करता है और उसी सेल में प्लॉट बनाने देता है।", "whatsNew.highlightLanguageAndHeader": "चुनी हुई प्रोग्रामिंग-भाषा की प्रविष्टि अब सूची में अपना पूरा नाम रखती है (Py - Python सहित), जबकि बंद नियंत्रण संक्षिप्त रहता है; ब्राउज़, फ़ॉर्मूला और टूर शॉर्टकट को हमेशा, जब जगह हो या कभी नहीं पर रखा जा सकता है।", "whatsNew.highlightAndroidAndWindows": "Android का वापस बटन अब नेविगेट करने या बाहर निकलने से पहले कीबोर्ड या सबसे ऊपर की SciREPL स्क्रीन बंद करता है, और टैग किए गए रिलीज़ में पोर्टेबल Windows बिल्ड भी शामिल है।", - "whatsNew.highlightAndroidFilesAndWindows": "Android का वापस बटन अब कीबोर्ड या सबसे ऊपर की SciREPL स्क्रीन को पहले बंद करता है; क्लाउड प्रदाताओं से वर्कबुक आयात करते समय सामान्य बाइनरी फ़ाइलें भी स्वीकार होती हैं; अंतिम वर्कबुक बंद करने पर निर्यात संबंधी मार्गदर्शन दिखता है और एक नई खाली वर्कबुक खुलती है; टैग किए गए रिलीज़ में पोर्टेबल Windows बिल्ड भी शामिल है।", + "whatsNew.highlightWorkbookClosing": "बंद की गई वर्कबुक अब ऐप दोबारा शुरू करने के बाद भी बंद रहती हैं। अंतिम वर्कबुक बंद करने पर निर्यात का स्मरण दिखता है, जिसे सेटिंग्स में फिर चालू किया जा सकता है।", + "whatsNew.highlightAndroidCloudImport": "Android अब उन वर्कबुक फ़ाइलों को आयात कर सकता है जिन्हें क्लाउड प्रदाता सामान्य बाइनरी फ़ाइल बताते हैं।", + "whatsNew.highlightAccessibility": "स्क्रीन रीडर के लिए स्पष्ट नाम और बेहतर कंट्रास्ट हेडर, टूर, आउटपुट लेबल और चलाएँ बटन को अधिक सुलभ बनाते हैं।", "whatsNew.releaseNotes": "पूरे रिलीज़ नोट पढ़ें", "menu.showTour": "🧭 टूर लें", "searchReplaceRow.replace": "बदलें", diff --git a/www/i18n/id.json b/www/i18n/id.json index 7407e28..81992d5 100644 --- a/www/i18n/id.json +++ b/www/i18n/id.json @@ -140,7 +140,7 @@ "whatsNew.version": "Versi {version}", "whatsNew.unreleasedVersion": "Perubahan yang belum dirilis · versi dasar {version}", "whatsNew.title": "Yang baru di SciREPL", - "whatsNew.intro": "Peningkatan utama yang ditambahkan sejak SciREPL 1.2.0:", + "whatsNew.intro": "Sorotan dalam pembaruan ini:", "whatsNew.highlightLanguages": "Antarmuka yang dilokalkan dan tur saat pertama kali dijalankan membantu lebih banyak orang memulai.", "whatsNew.highlightShortcuts": "Pilih apakah pintasan Tur dan Rumus muncul di header.", "whatsNew.highlightOffline": "Peningkatan luring yang konsisten dengan versi mencegah pencampuran berkas dari rilis yang berbeda.", @@ -154,7 +154,9 @@ "whatsNew.highlightPipPackages": "%pip install dapat mengambil paket Pyodide kompatibel dan dependensi yang belum ada dari CDN resmi, mematuhi persyaratan versi, dan mendukung pembuatan plot di sel yang sama.", "whatsNew.highlightLanguageAndHeader": "Entri bahasa pemrograman yang dipilih kini tetap memakai nama lengkap di daftar (termasuk Py - Python), sementara kontrol tertutup tetap ringkas; pintasan Jelajahi, Rumus, dan Tur dapat diatur ke Selalu, Bila ada ruang, atau Tidak pernah.", "whatsNew.highlightAndroidAndWindows": "Tombol Kembali Android kini menutup papan ketik atau layar SciREPL teratas sebelum bernavigasi atau keluar, dan rilis bertag menyertakan build Windows portabel.", - "whatsNew.highlightAndroidFilesAndWindows": "Tombol Kembali Android kini menutup papan ketik atau layar SciREPL teratas terlebih dahulu; impor buku kerja dari penyedia cloud menerima berkas biner generik; menutup buku kerja terakhir menampilkan panduan ekspor dan membuka buku kerja pengganti yang kosong; rilis bertag menyertakan build Windows portabel.", + "whatsNew.highlightWorkbookClosing": "Buku kerja yang ditutup kini tetap tertutup setelah aplikasi dimulai ulang. Menutup buku kerja terakhir menampilkan pengingat ekspor yang dapat diaktifkan kembali di Pengaturan.", + "whatsNew.highlightAndroidCloudImport": "Android kini dapat mengimpor berkas buku kerja yang dilabeli penyedia cloud sebagai berkas biner umum.", + "whatsNew.highlightAccessibility": "Nama pembaca layar yang lebih jelas dan kontras yang lebih kuat meningkatkan header, Tur, label keluaran, dan tombol Jalankan.", "whatsNew.releaseNotes": "Baca catatan rilis lengkap", "menu.showTour": "🧭 Ikuti tur", "searchReplaceRow.replace": "Ganti", diff --git a/www/i18n/ja.json b/www/i18n/ja.json index b389f9e..0929db1 100644 --- a/www/i18n/ja.json +++ b/www/i18n/ja.json @@ -133,7 +133,7 @@ "whatsNew.version": "バージョン {version}", "whatsNew.unreleasedVersion": "未リリースの変更 · ベースバージョン {version}", "whatsNew.title": "SciREPL の新機能", - "whatsNew.intro": "SciREPL 1.2.0 以降に追加された主な改善点:", + "whatsNew.intro": "このアップデートの主な変更点:", "whatsNew.highlightLanguages": "ローカライズされたインターフェースと初回起動時のツアーにより、より多くの人が簡単に使い始められます。", "whatsNew.highlightShortcuts": "ツアーと数式のショートカットをヘッダーに表示するかどうかを選べます。", "whatsNew.highlightOffline": "バージョン単位で整合性を保つオフライン更新により、異なるリリースのファイルが混在するのを防ぎます。", @@ -147,7 +147,9 @@ "whatsNew.highlightPipPackages": "%pip install は、不足している互換性のある Pyodide パッケージと依存関係を公式 CDN から取得し、バージョン要件を守り、同じセル内でのプロットにも対応します。", "whatsNew.highlightLanguageAndHeader": "選択中のプログラミング言語も一覧では完全な名前(Py - Python など)で表示され、閉じたコントロールはコンパクトなままです。「参照」「数式」「ツアー」のショートカットは「常に表示」「余裕があるとき」「表示しない」から選べます。", "whatsNew.highlightAndroidAndWindows": "Android の戻るボタンは、移動や終了の前にキーボードまたは最前面の SciREPL 画面を閉じるようになりました。タグ付きリリースにはポータブル Windows ビルドも含まれます。", - "whatsNew.highlightAndroidFilesAndWindows": "Android の戻るボタンはキーボードまたは最前面の SciREPL 画面を先に閉じるようになりました。クラウドプロバイダーからのワークブックのインポートでは汎用バイナリファイルも受け付けます。最後のワークブックを閉じる際はエクスポート方法を案内し、新しい空のワークブックを開きます。タグ付きリリースにはポータブル Windows ビルドも含まれます。", + "whatsNew.highlightWorkbookClosing": "閉じたワークブックは、アプリを再起動しても閉じたままになります。最後のワークブックを閉じる際はエクスポートの案内が表示され、設定から再度有効にできます。", + "whatsNew.highlightAndroidCloudImport": "Android では、クラウドプロバイダーが汎用バイナリファイルとして扱うワークブックもインポートできるようになりました。", + "whatsNew.highlightAccessibility": "スクリーンリーダー向けの明確な名前と高いコントラストにより、ヘッダー、ツアー、出力ラベル、実行ボタンが使いやすくなりました。", "whatsNew.releaseNotes": "リリースノートをすべて読む", "menu.showTour": "🧭 ツアーを開始", "searchReplaceRow.replace": "置換", diff --git a/www/i18n/ko.json b/www/i18n/ko.json index 10009be..47033ba 100644 --- a/www/i18n/ko.json +++ b/www/i18n/ko.json @@ -132,7 +132,7 @@ "whatsNew.version": "버전 {version}", "whatsNew.unreleasedVersion": "출시되지 않은 변경 사항 · 기준 버전 {version}", "whatsNew.title": "SciREPL의 새로운 기능", - "whatsNew.intro": "SciREPL 1.2.0 이후 추가된 주요 개선 사항:", + "whatsNew.intro": "이번 업데이트의 주요 내용:", "whatsNew.highlightLanguages": "현지화된 인터페이스와 첫 실행 둘러보기를 통해 더 많은 사용자가 쉽게 시작할 수 있습니다.", "whatsNew.highlightShortcuts": "둘러보기와 수식 바로가기를 헤더에 표시할지 선택할 수 있습니다.", "whatsNew.highlightOffline": "버전 일관성을 유지하는 오프라인 업그레이드로 서로 다른 릴리스의 파일이 섞이지 않습니다.", @@ -146,7 +146,9 @@ "whatsNew.highlightPipPackages": "%pip install은 누락된 호환 Pyodide 패키지와 종속성을 공식 CDN에서 가져오고, 버전 요구 사항을 준수하며, 같은 셀에서 그래프를 그릴 수 있습니다.", "whatsNew.highlightLanguageAndHeader": "선택된 프로그래밍 언어도 목록에서는 전체 이름(Py - Python 포함)을 유지하고 닫힌 컨트롤은 간결하게 표시됩니다. 찾아보기·수식·둘러보기 바로가기는 항상, 공간이 있을 때 또는 표시 안 함으로 설정할 수 있습니다.", "whatsNew.highlightAndroidAndWindows": "Android 뒤로 버튼은 이동하거나 종료하기 전에 키보드 또는 맨 위의 SciREPL 화면을 먼저 닫으며, 태그가 지정된 릴리스에는 포터블 Windows 빌드도 포함됩니다.", - "whatsNew.highlightAndroidFilesAndWindows": "Android 뒤로 버튼은 이제 키보드 또는 맨 위의 SciREPL 화면을 먼저 닫습니다. 클라우드 제공업체에서 워크북을 가져올 때 일반 바이너리 파일도 허용합니다. 마지막 워크북을 닫으면 내보내기 안내를 표시하고 새 빈 워크북을 엽니다. 태그가 지정된 릴리스에는 포터블 Windows 빌드도 포함됩니다.", + "whatsNew.highlightWorkbookClosing": "닫은 워크북은 앱을 다시 시작해도 닫힌 상태로 유지됩니다. 마지막 워크북을 닫으면 내보내기 알림이 표시되며, 설정에서 다시 켤 수 있습니다.", + "whatsNew.highlightAndroidCloudImport": "Android에서는 클라우드 제공업체가 일반 바이너리 파일로 표시한 워크북 파일도 가져올 수 있습니다.", + "whatsNew.highlightAccessibility": "더 명확한 스크린 리더 이름과 향상된 대비로 헤더, 둘러보기, 출력 레이블 및 실행 버튼을 더 쉽게 사용할 수 있습니다.", "whatsNew.releaseNotes": "전체 릴리스 노트 읽기", "menu.showTour": "🧭 둘러보기 시작", "searchReplaceRow.replace": "바꾸기", diff --git a/www/i18n/pt-BR.json b/www/i18n/pt-BR.json index 7a69d7f..6228270 100644 --- a/www/i18n/pt-BR.json +++ b/www/i18n/pt-BR.json @@ -139,7 +139,7 @@ "whatsNew.version": "Versão {version}", "whatsNew.unreleasedVersion": "Alterações não lançadas · versão-base {version}", "whatsNew.title": "Novidades do SciREPL", - "whatsNew.intro": "Destaques adicionados desde o SciREPL 1.2.0:", + "whatsNew.intro": "Destaques desta atualização:", "whatsNew.highlightLanguages": "Uma interface localizada e um tour na primeira inicialização ajudam mais pessoas a começar.", "whatsNew.highlightShortcuts": "Escolha se os atalhos do tour e de Fórmula aparecem no cabeçalho.", "whatsNew.highlightOffline": "Atualizações offline coerentes com a versão evitam misturar arquivos de versões diferentes.", @@ -153,7 +153,9 @@ "whatsNew.highlightPipPackages": "%pip install pode buscar no CDN oficial pacotes compatíveis do Pyodide e dependências ausentes, respeita os requisitos de versão e permite gerar gráficos na mesma célula.", "whatsNew.highlightLanguageAndHeader": "A opção de linguagem selecionada agora mantém o nome completo na lista (incluindo Py - Python), enquanto o controle fechado continua compacto; os atalhos Navegar, Fórmula e Tour podem ser Sempre, Quando houver espaço ou Nunca.", "whatsNew.highlightAndroidAndWindows": "O botão Voltar do Android agora fecha o teclado ou a tela superior do SciREPL antes de navegar ou sair, e os lançamentos com tag incluem uma versão portátil para Windows.", - "whatsNew.highlightAndroidFilesAndWindows": "O botão Voltar do Android agora fecha primeiro o teclado ou a tela superior do SciREPL; a importação de pastas de trabalho de provedores de nuvem aceita arquivos binários genéricos; ao fechar a última pasta de trabalho, o aplicativo mostra orientações de exportação e abre uma nova pasta de trabalho vazia; os lançamentos com tag incluem uma versão portátil para Windows.", + "whatsNew.highlightWorkbookClosing": "As pastas de trabalho fechadas agora permanecem fechadas após reiniciar o aplicativo. Ao fechar a última pasta, aparece um lembrete de exportação que pode ser reativado em Configurações.", + "whatsNew.highlightAndroidCloudImport": "O Android agora pode importar arquivos de pasta de trabalho que provedores de nuvem rotulam como binários genéricos.", + "whatsNew.highlightAccessibility": "Nomes mais claros para leitores de tela e maior contraste melhoram o cabeçalho, o Tour, os rótulos de saída e o botão Executar.", "whatsNew.releaseNotes": "Ler as notas de lançamento completas", "menu.showTour": "🧭 Fazer o tour", "searchReplaceRow.replace": "Substituir", diff --git a/www/i18n/ru.json b/www/i18n/ru.json index 322d44d..301003b 100644 --- a/www/i18n/ru.json +++ b/www/i18n/ru.json @@ -132,7 +132,7 @@ "whatsNew.version": "Версия {version}", "whatsNew.unreleasedVersion": "Неопубликованные изменения · базовая версия {version}", "whatsNew.title": "Что нового в SciREPL", - "whatsNew.intro": "Основные улучшения со времени SciREPL 1.2.0:", + "whatsNew.intro": "Основные изменения в этом обновлении:", "whatsNew.highlightLanguages": "Локализованный интерфейс и тур при первом запуске помогают большему числу людей начать работу.", "whatsNew.highlightShortcuts": "Выберите, показывать ли ярлыки тура и формул в заголовке.", "whatsNew.highlightOffline": "Согласованные по версии автономные обновления не допускают смешивания файлов из разных выпусков.", @@ -146,7 +146,9 @@ "whatsNew.highlightPipPackages": "%pip install может загружать недостающие совместимые пакеты Pyodide и зависимости с официального CDN, соблюдает требования к версиям и поддерживает построение графиков в той же ячейке.", "whatsNew.highlightLanguageAndHeader": "Выбранный язык теперь сохраняет полное название в списке (включая Py - Python), а закрытый элемент остаётся компактным; для ярлыков «Обзор», «Формулы» и «Тур» можно выбрать Всегда, Когда есть место или Никогда.", "whatsNew.highlightAndroidAndWindows": "Кнопка «Назад» в Android теперь сначала закрывает клавиатуру или верхний экран SciREPL, а уже затем выполняет переход или выход; выпуски с тегами также содержат переносимую сборку для Windows.", - "whatsNew.highlightAndroidFilesAndWindows": "Кнопка «Назад» в Android теперь сначала закрывает клавиатуру или верхний экран SciREPL; при импорте книг из облачных хранилищ принимаются и файлы с универсальным бинарным типом; при закрытии последней книги показывается совет по экспорту и открывается новая пустая книга; выпуски с тегами также содержат переносимую сборку для Windows.", + "whatsNew.highlightWorkbookClosing": "Закрытые книги теперь остаются закрытыми после перезапуска приложения. При закрытии последней книги появляется напоминание об экспорте, которое можно снова включить в настройках.", + "whatsNew.highlightAndroidCloudImport": "Android теперь может импортировать файлы книг, которые облачные провайдеры помечают как обычные двоичные файлы.", + "whatsNew.highlightAccessibility": "Более понятные названия для программ чтения с экрана и усиленный контраст улучшают заголовок, тур, подписи вывода и кнопку запуска.", "whatsNew.releaseNotes": "Прочитать полные примечания к выпуску", "menu.showTour": "🧭 Пройти тур", "searchReplaceRow.replace": "Заменить", diff --git a/www/i18n/zh.json b/www/i18n/zh.json index 9cee700..5163239 100644 --- a/www/i18n/zh.json +++ b/www/i18n/zh.json @@ -132,7 +132,7 @@ "whatsNew.version": "版本 {version}", "whatsNew.unreleasedVersion": "未发布的更改 · 基础版本 {version}", "whatsNew.title": "SciREPL 新增功能", - "whatsNew.intro": "自 SciREPL 1.2.0 以来新增的主要改进:", + "whatsNew.intro": "本次更新的主要内容:", "whatsNew.highlightLanguages": "本地化界面和首次启动导览可帮助更多人轻松上手。", "whatsNew.highlightShortcuts": "选择是否在页眉中显示导览和公式快捷方式。", "whatsNew.highlightOffline": "保持版本一致的离线升级可避免混用不同版本的文件。", @@ -146,7 +146,9 @@ "whatsNew.highlightPipPackages": "%pip install 可从官方 CDN 获取缺少的兼容 Pyodide 软件包及其依赖项,遵守版本要求,并支持在同一个单元格中绘图。", "whatsNew.highlightLanguageAndHeader": "选中的编程语言现在也会在列表中保留完整名称(包括 Py - Python),而关闭时的控件仍保持紧凑;“浏览”“公式”和“导览”快捷方式可设为“始终显示”“空间足够时”或“从不显示”。", "whatsNew.highlightAndroidAndWindows": "Android 返回键现在会先关闭键盘或最上层的 SciREPL 界面,再执行导航或退出;带标签的版本还包含便携式 Windows 构建。", - "whatsNew.highlightAndroidFilesAndWindows": "Android 返回键现在会先关闭键盘或最上层的 SciREPL 界面;从云存储提供商导入工作簿时可接受通用二进制文件;关闭最后一个工作簿时会显示导出提示并打开一个新的空白工作簿;带标签的版本还包含便携式 Windows 构建。", + "whatsNew.highlightWorkbookClosing": "关闭的工作簿在应用重启后仍会保持关闭。关闭最后一个工作簿时会显示导出提醒,并可在“设置”中重新启用该提醒。", + "whatsNew.highlightAndroidCloudImport": "Android 现在可以导入被云存储提供商标记为通用二进制文件的工作簿。", + "whatsNew.highlightAccessibility": "更清晰的屏幕阅读器名称和更强的对比度改善了标题栏、导览、输出标签和运行按钮。", "whatsNew.releaseNotes": "阅读完整发布说明", "menu.showTour": "🧭 开始导览", "searchReplaceRow.replace": "替换", diff --git a/www/index.html b/www/index.html index cf57c8e..f587c3f 100644 --- a/www/index.html +++ b/www/index.html @@ -68,12 +68,12 @@

Sci REPL

- + - + - - + + loading…
diff --git a/www/js/file_io.js b/www/js/file_io.js index 6f4c1c6..f448e35 100644 --- a/www/js/file_io.js +++ b/www/js/file_io.js @@ -102,10 +102,26 @@ class FileIO { 'SciREPL auto-saves open workbooks, but closing “{name}” removes it from this app. Export it first if you want to keep a file copy.', { name }); if (dontShow) dontShow.checked = false; + const opener = document.activeElement; return new Promise(resolve => { let settled = false; + let focusFrame = null; + const focusable = () => [...modal.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')] + .filter(element => !element.disabled && element.offsetParent !== null); + const visible = element => { + if (!element || !document.contains(element)) return false; + if (element.disabled || element.closest?.('.modal.hidden')) return false; + const style = getComputedStyle(element); + return style.display !== 'none' && style.visibility !== 'hidden' + && element.getBoundingClientRect().width > 0; + }; const cleanup = () => { + if (focusFrame !== null) { + cancelAnimationFrame(focusFrame); + focusFrame = null; + } closeBtn?.removeEventListener('click', onCancel); cancelBtn?.removeEventListener('click', onCancel); confirmBtn?.removeEventListener('click', onConfirm); @@ -120,6 +136,8 @@ class FileIO { } modal.classList.add('hidden'); cleanup(); + const returnTarget = visible(opener) ? opener : this.menuBtn; + returnTarget?.focus(); resolve(accepted); }; const onCancel = () => finish(false); @@ -130,7 +148,23 @@ class FileIO { const onKey = event => { if (event.key === 'Escape') { event.preventDefault(); + event.stopPropagation(); finish(false); + return; + } + if (event.key !== 'Tab') return; + const nodes = focusable(); + if (!nodes.length) return; + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + const active = document.activeElement; + if (event.shiftKey && (active === first || !modal.contains(active))) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey + && (active === last || !modal.contains(active))) { + event.preventDefault(); + first.focus(); } }; @@ -142,7 +176,10 @@ class FileIO { modal.classList.remove('hidden'); modal.inert = false; modal.removeAttribute('aria-hidden'); - requestAnimationFrame(() => cancelBtn?.focus()); + focusFrame = requestAnimationFrame(() => { + focusFrame = null; + if (!settled) (cancelBtn || focusable()[0])?.focus(); + }); }); } @@ -510,7 +547,6 @@ class FileIO { const nm = window.notebookManager; const active = nm && nm.getActiveNotebook(); if (!active) return; - closeNotebookBtn.disabled = true; let accepted = false; try { if (nm.getNotebooks().length === 1 @@ -521,8 +557,10 @@ class FileIO { 'notebookManager.confirm.closeNamed', { name: active.name })); } if (accepted) { + closeNotebookBtn.disabled = true; await nm.closeNotebook(active.id); this.menuModal.classList.add('hidden'); + this.menuBtn?.focus(); } } finally { closeNotebookBtn.disabled = false; @@ -2419,6 +2457,9 @@ class FileIO { try { if (created && target) { if (previous) nm.switchTo(previous.id); + // This target was never committed as an imported workbook. + // Roll it back in memory without recording a user-close + // tombstone or performing a destructive persistence flush. if (nm.getNotebooks().length > 1) nm.removeNotebook(target.id); } else if (snapshot && previous) { this._renderImportedNotebook( diff --git a/www/js/notebook_manager.js b/www/js/notebook_manager.js index 4971edf..6e3262e 100644 --- a/www/js/notebook_manager.js +++ b/www/js/notebook_manager.js @@ -692,7 +692,7 @@ class NotebookManager { deleteBtn.textContent = '\u00D7'; window.setI18nAttr(deleteBtn, 'title', 'notebookManager.actions.deleteTitle'); deleteBtn.style.color = 'var(--red, #f85149)'; - deleteBtn.addEventListener('click', () => { + deleteBtn.addEventListener('click', async () => { const active = this.getActiveNotebook(); if (!active) return; if (this._notebooks.length <= 1) { @@ -700,7 +700,12 @@ class NotebookManager { return; } if (confirm((window.tNative || window.t)('notebookManager.confirm.deleteNamed', { name: active.name }))) { - this.removeNotebook(active.id); + deleteBtn.disabled = true; + try { + await this.closeNotebook(active.id); + } finally { + if (deleteBtn.isConnected) deleteBtn.disabled = false; + } } }); @@ -789,10 +794,15 @@ class NotebookManager { closeBtn.className = 'sidebar-nb-close'; closeBtn.textContent = '\u00D7'; window.setI18nAttr(closeBtn, 'title', 'notebookManager.actions.closeTitle'); - closeBtn.addEventListener('click', (e) => { + closeBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (confirm((window.tNative || window.t)('notebookManager.confirm.closeNamed', { name: nb.name }))) { - this.removeNotebook(nb.id); + closeBtn.disabled = true; + try { + await this.closeNotebook(nb.id); + } finally { + if (closeBtn.isConnected) closeBtn.disabled = false; + } } }); item.appendChild(closeBtn); @@ -853,10 +863,15 @@ class NotebookManager { closeBtn.className = 'tab-close'; closeBtn.textContent = '\u00D7'; window.setI18nAttr(closeBtn, 'title', 'notebookManager.actions.closeTitle'); - closeBtn.addEventListener('click', (e) => { + closeBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (confirm((window.tNative || window.t)('notebookManager.confirm.closeNamed', { name: nb.name }))) { - this.removeNotebook(nb.id); + closeBtn.disabled = true; + try { + await this.closeNotebook(nb.id); + } finally { + if (closeBtn.isConnected) closeBtn.disabled = false; + } } }); tab.appendChild(closeBtn); diff --git a/www/js/onboarding.js b/www/js/onboarding.js index 2123e34..26b462d 100644 --- a/www/js/onboarding.js +++ b/www/js/onboarding.js @@ -347,6 +347,9 @@ _languagePicker() { const wrap = document.createElement('div'); wrap.className = 'tour-language'; + const languageLabel = document.createElement('label'); + languageLabel.setAttribute('for', 'tour-language-select'); + languageLabel.textContent = (window.t || ((k) => k))('whatsNew.displayLanguage'); const select = document.createElement('select'); select.className = 'settings-select'; select.id = 'tour-language-select'; @@ -390,7 +393,7 @@ if (fresh) fresh.focus(); }); - wrap.appendChild(select); + wrap.append(languageLabel, select); // The Tour shortcut is useful while learning, but it costs scarce // header space on a phone. Put the choice in the very first tour diff --git a/www/js/release_highlights.js b/www/js/release_highlights.js index af0c672..22a1f82 100644 --- a/www/js/release_highlights.js +++ b/www/js/release_highlights.js @@ -34,11 +34,9 @@ 'whatsNew.highlightAndroidAndWindows', ]), '1.3.2': Object.freeze([ - 'whatsNew.highlightCatalogBrowse', - 'whatsNew.highlightFormulaContexts', - 'whatsNew.highlightPipPackages', - 'whatsNew.highlightLanguageAndHeader', - 'whatsNew.highlightAndroidFilesAndWindows', + 'whatsNew.highlightWorkbookClosing', + 'whatsNew.highlightAndroidCloudImport', + 'whatsNew.highlightAccessibility', ]), unreleased: Object.freeze([]), }); diff --git a/www/sw-shell.lock.json b/www/sw-shell.lock.json index 43fbb39..c18c515 100644 --- a/www/sw-shell.lock.json +++ b/www/sw-shell.lock.json @@ -78,6 +78,7 @@ "v204": "c918f90c720c80afd6e2acf0cd0f0afbc589c291b4c5516720ef1cf1c82c822f", "v205": "b02134f6808ba803d0429067fca3d026b78b2303556f796f7aa86c12fdd5277a", "v206": "f13aba7ad859b2f13204d2eb1f2823c9c2a032a5cbf1ab4ee099798be2884302", - "v207": "0d81e4f944c900d74bbeec507f805b6f266c8d645030b0dd079ba075b29cca1f" + "v207": "0d81e4f944c900d74bbeec507f805b6f266c8d645030b0dd079ba075b29cca1f", + "v208": "3018225f2fc7f9c998d7a262ef18bfa0dd3063324bea1b52e51697543592a9de" } } diff --git a/www/sw.js b/www/sw.js index 5391262..da9059f 100644 --- a/www/sw.js +++ b/www/sw.js @@ -1,7 +1,7 @@ // Service Worker for SciREPL PWA // Caches app shell on install, caches CDN runtimes (Pyodide, swipl-wasm) on first fetch. -const CACHE_VERSION = 'v207'; +const CACHE_VERSION = 'v208'; // Marker entry recording whether an app cache finished installing. Stored in // the cache itself so the answer travels with it and survives a restart.