diff --git a/e2e/rtl-mirroring.spec.js b/e2e/rtl-mirroring.spec.js index 84e0a7f0..128838e6 100644 --- a/e2e/rtl-mirroring.spec.js +++ b/e2e/rtl-mirroring.spec.js @@ -63,3 +63,43 @@ test('a row title and its subtitle share a leading edge', async ({ page, request const gap = Math.abs(name - meta); expect(gap, `the title and its subtitle start ${Math.round(gap)}px apart`).toBeLessThan(8); }); + +/* A tile name takes the direction of its own script, whatever the page's. The + localisation clipping check skips anything with an ellipsis, so a name cut + from the wrong end passes there. WebKit reports a clipped first glyph inside + the box, so the computed direction is the check that holds in both engines. */ +for (const language of ['en', 'fa']) { + test(`a long tile name keeps its start on a ${language} dashboard`, async ({ page, request }) => { + const names = [ + ['fa', 'مدیریت سرورهای خانگی و پشتیبان‌گیری خودکار شبانه', 'rtl'], + ['en', 'Benachrichtigungseinstellungen Verwaltung', 'ltr'], + ]; + await seedConfig(request, { + items: names.map(([id, label]) => app(id, label)), + settings: { language, showLabels: { desktop: true } }, + }); + await dismissSetupPrompt(request); + await page.goto('/'); + for (const [id, label, dir] of names) { + const tile = page.locator(`#pages .icon[data-tile-name="${label}"] .ilabel`); + await tile.waitFor({ state: 'visible' }); + const seen = await tile.evaluate(el => { + const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + const text = /** @type {Text} */ (walk.nextNode()); + const range = document.createRange(); + range.setStart(text, 0); + range.setEnd(text, 1); + const glyph = range.getBoundingClientRect(); + const box = el.getBoundingClientRect(); + return { + dir: getComputedStyle(el).direction, + cut: el.scrollWidth > el.clientWidth, + inside: glyph.left >= box.left - 1 && glyph.right <= box.right + 1, + }; + }); + expect(seen.cut, `${id}: the name is not long enough to be cut`).toBe(true); + expect(seen.dir, `${id}: the label follows the page, not its text`).toBe(dir); + expect(seen.inside, `${id}: the first letter is cut off`).toBe(true); + } + }); +} diff --git a/ui/admin/index.html b/ui/admin/index.html index ba74bc5a..95cccd80 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -10,8 +10,8 @@ Stackyard Settings - - + + @@ -319,6 +319,6 @@

General

- + diff --git a/ui/css/admin.css b/ui/css/admin.css index 7b0193fb..a98e9ae2 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -902,10 +902,9 @@ html.is-mobile .rord{width:28px;align-self:stretch;font-size:var(--fs-subheadlin .mp.on{ opacity:1; transition:opacity .12s ease, - transform .19s cubic-bezier(.22,1,.36,1), - width .19s cubic-bezier(.22,1,.36,1), - height .19s cubic-bezier(.22,1,.36,1); + transform .19s cubic-bezier(.22,1,.36,1); } +.mp.mp-snap{transition:opacity .12s ease} /* The pill precedes the rows, so a position is all they need to paint above it. No z-index: one on a row makes it a stacking context, and the dropdown inside that row is then trapped behind every row that follows it. */ diff --git a/ui/css/dashboard.css b/ui/css/dashboard.css index df4a86e2..785783fa 100644 --- a/ui/css/dashboard.css +++ b/ui/css/dashboard.css @@ -15,7 +15,7 @@ --label-color-dark: rgba(0,0,0,.92); --label-shadow-dark: 0 0 2px rgba(255,255,255,.55); --bounce-spring: cubic-bezier(.34,1.56,.64,1); - --page-ease: cubic-bezier(.77,0,.175,1); + --page-ease: cubic-bezier(.32,.72,0,1); /* The page behind the wallpaper, and the boot veil over the unstyled first paint. */ /* Darker than the system greys: it sits behind the wallpaper. */ @@ -34,17 +34,18 @@ /* ── Root ── */ html { width:100vw; height:100vh; height:100dvh; overflow:hidden; + overscroll-behavior:none; -webkit-text-size-adjust:100%; text-size-adjust:100%; background: var(--bg-color, var(--bg-base)); font-family:var(--font-ui); -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; } /* background, position and z-index come from tokens.css. */ - body { width:100vw; height:100vh; height:100dvh; overflow:hidden; } + body { width:100vw; height:100vh; height:100dvh; overflow:hidden; overscroll-behavior:none; } /* ── Boot loading veil — fades out once boot() finishes ── */ body::after { content:''; position:fixed; inset:0; z-index:9999; background:var(--bg-base); pointer-events:none; - transition:opacity .4s ease; + transition:opacity .2s ease-out; } body.ready::after { opacity:0; } @@ -72,7 +73,7 @@ #pages { position:fixed; inset:0; z-index:10; display:flex; - transition:transform .44s var(--page-ease); + transition:transform .28s var(--page-ease); } .page { flex:0 0 100vw; width:100vw; height:100vh; height:100dvh; @@ -233,21 +234,28 @@ clears neither tone. */ .fb.fb-on-light { color:var(--on-tint-dark) } - .iwrap, .dwrap { transition:transform .32s var(--bounce-spring), filter .2s ease-out } + .iwrap, .dwrap, .dyn-folder-box, + .dyn-mob-icon > .plate, .dyn-dock-icon > .plate, .dyn-mob-btn > .dyn-box, .dyn-fold-anchor > .plate { transition:transform .32s var(--bounce-spring), filter .2s ease-out } @media (hover:hover) { .icon:hover .iwrap, + .icon:hover .dyn-folder-box, .folder-icon-link:hover .iwrap, - .di:hover .dwrap { transform:scale(1.03) } + .di:hover .dwrap, + .dyn-mob-icon:hover > .plate, .dyn-dock-icon:hover > .plate, .dyn-mob-btn:hover > .dyn-box, .dyn-fold-anchor:hover > .plate { transform:scale(1.03) } } .icon:active .iwrap, + .icon:active .dyn-folder-box, .folder-icon-link:active .iwrap, - .di:active .dwrap { + .di:active .dwrap, + .dyn-mob-icon:active > .plate, .dyn-dock-icon:active > .plate, .dyn-mob-btn:active > .dyn-box, .dyn-fold-anchor:active > .plate { transform:scale(.92); filter:brightness(.88); transition:transform .1s ease-out, filter .1s ease-out; } .icon.settled .iwrap, .folder-icon-link.settled .iwrap, - .di.settled .dwrap { transform:none; filter:none; transition:none } + .di.settled .dwrap, + .dyn-mob-icon.settled > .plate, .dyn-dock-icon.settled > .plate, .dyn-fold-anchor.settled > .plate { transform:none; filter:none; transition:none } + :is(#pages, .dyn-strip).dragging :is(.plate, .dyn-box, .dyn-folder-box) { transform:none; filter:none } /* ── Icon label ── */ .ilabel { @@ -417,7 +425,7 @@ -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; overflow:hidden; - transition:width .25s cubic-bezier(.4,0,.2,1); + transition:clip-path .25s cubic-bezier(.4,0,.2,1); } /* Search content — shown when idle */ #mob-search-pill .msp-search { @@ -513,7 +521,7 @@ max-width:none; max-height:none; width:auto; height:auto; margin:0; padding:0; border:0; color:inherit; display:none; opacity:0; - transition:opacity .2s ease; + transition:opacity .15s ease-out; } #spot::backdrop { background:none } #spot.on { display:flex; @@ -617,6 +625,9 @@ to. At .38 it did not over a light one. */ #sin::placeholder { color:rgba(255,255,255,.55); } #spot #sin::placeholder { color:rgba(235,235,245,.55); } + /* Closed and still fading out. It must not catch input. */ + #spot:not([open]) { pointer-events:none } + body:has(#spot[open]) #mob-search-pill { visibility:hidden } #spot-cancel { display:flex; flex-shrink:0; background:transparent; border:0; outline:0; cursor:pointer; -webkit-tap-highlight-color:transparent; @@ -669,7 +680,7 @@ #sres .sri img { width:34px !important; height:34px !important; } #sres .sri img.folder-mini-img, body.is-mob #sres .sri img.folder-mini-img { width:72% !important; height:72% !important; } - .srn { font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body); font-weight:400; color:rgba(255,255,255,.92) } + .srn { font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body); font-weight:400; color:rgba(255,255,255,.92); overflow-wrap:anywhere } .srh { font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote); color:rgba(255,255,255,.65); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; @@ -723,6 +734,12 @@ margin:0; padding:0; border:0; color:inherit; } .folder-overlay::backdrop, .folder-overlay-mobile::backdrop { background:none } + .folder-overlay-mobile:not([open]) { display:block } + :is(.folder-overlay, .folder-overlay-mobile):not([open]), + :is(.folder-overlay, .folder-overlay-mobile):not([open]) * { pointer-events:none !important } + :is(.folder-overlay, .folder-overlay-mobile):not([open]) { + background:none !important; -webkit-backdrop-filter:none !important; backdrop-filter:none !important; + } .folder-overlay-mobile { position:fixed; inset:0; z-index:800; -webkit-backdrop-filter:blur(22px) brightness(.72); @@ -802,13 +819,13 @@ .dyn-folder-box { position:relative; width:var(--iw); height:var(--iw); flex-shrink:0; } .dyn-grid { display:grid; grid-template-columns:repeat(6,var(--iw)); gap:24px; justify-content:center; } .dyn-mob-btn { flex:1; height:var(--rh); background:none; border:none; padding:0; display:flex; flex-direction:column; align-items:center; justify-content:center; cursor:pointer; flex-shrink:0; -webkit-tap-highlight-color:transparent; -webkit-appearance:none; } - .dyn-mob-icon { display:flex; flex-direction:column; align-items:center; justify-content:center; width:var(--cw); height:var(--rh); cursor:pointer; text-decoration:none; flex-shrink:0; -webkit-tap-highlight-color:transparent; } + .dyn-mob-icon { display:flex; flex-direction:column; align-items:center; justify-content:center; width:var(--cw); height:var(--rh); cursor:pointer; text-decoration:none; flex-shrink:0; -webkit-tap-highlight-color:transparent; -webkit-touch-callout:none; } .dyn-mob-label { font-weight:500; color:#FFFFFF; text-align:center; margin-top:5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-shadow:0 1px 3px rgba(0,0,0,.65); font-size:var(--lfs); width:var(--lw); } .dyn-fold-label { font-weight:500; color:#FFFFFF; text-align:center; margin-top:5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-shadow:0 1px 3px rgba(0,0,0,.70); font-size:var(--lfs); width:var(--lw); } .dyn-fold-wrap { position:absolute; inset:0; pointer-events:none; border-radius:var(--br); display:grid; grid-template-columns:repeat(3,1fr); grid-template-rows:repeat(3,1fr); gap:var(--gap); padding:var(--pad); overflow:hidden; } .dyn-fold-cell { display:flex; align-items:center; justify-content:center; z-index:1; pointer-events:none; min-width:0; min-height:0; } .dyn-clip { overflow:hidden; flex-shrink:0; width:var(--gw); height:var(--gh); } - .dyn-strip { display:flex; height:var(--gh); will-change:transform; transition:transform .3s cubic-bezier(.4,0,.2,1); } + .dyn-strip { display:flex; height:var(--gh); will-change:transform; transition:transform .28s var(--page-ease); } .dyn-page-grid { flex-shrink:0; display:grid; gap:var(--gap); justify-content:center; align-content:start; width:var(--gw); height:var(--gh); padding:var(--ovh,0); overflow:hidden; grid-template-columns:repeat(3,var(--fiw)); grid-template-rows:repeat(3,var(--fiw)); } .dyn-dot { border-radius:50%; cursor:pointer; flex-shrink:0; width:var(--dsz); height:var(--dsz); } .dyn-dots-row { display:flex; justify-content:center; align-items:center; flex-shrink:0; width:100%; } @@ -819,8 +836,8 @@ .dyn-fold-anchor { display:flex; flex-direction:column; align-items:center; gap:4px; text-decoration:none; -webkit-tap-highlight-color:transparent; } .dyn-fold-inner-label { font-weight:400; color:rgba(255,255,255,.90); text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-shadow:0 1px 3px rgba(0,0,0,.6); font-size:var(--lfs); width:var(--fiw); } .dyn-title-mob { position:absolute; pointer-events:none; font-size:var(--tfs); } - .dyn-box-mob { position:absolute; display:flex; flex-direction:column; align-items:center; inset-inline-start:var(--left); width:var(--bw); height:var(--bh); top:var(--top); border-radius:var(--br); padding:var(--pt) var(--ph) var(--pb); } - .dyn-dock-icon { display:flex; align-items:center; justify-content:center; flex:0 1 auto; min-width:0; height:100%; cursor:pointer; text-decoration:none; -webkit-tap-highlight-color:transparent; overflow:visible; position:relative; } + .dyn-box-mob { position:absolute; touch-action:none; display:flex; flex-direction:column; align-items:center; inset-inline-start:var(--left); width:var(--bw); height:var(--bh); top:var(--top); border-radius:var(--br); padding:var(--pt) var(--ph) var(--pb); } + .dyn-dock-icon { display:flex; align-items:center; justify-content:center; flex:0 1 auto; min-width:0; height:100%; cursor:pointer; text-decoration:none; -webkit-tap-highlight-color:transparent; -webkit-touch-callout:none; overflow:visible; position:relative; } /* Strip backdrop-filter blur for anyone who asks for reduced motion or transparency. */ @@ -839,10 +856,18 @@ #pages { transition: none !important; } .icon:hover .iwrap, .folder-icon-link:hover .iwrap, .di:hover .dwrap, .icon:active .iwrap, .folder-icon-link:active .iwrap, .di:active .dwrap, + .icon .dyn-folder-box, .dyn-mob-icon > .plate, .dyn-dock-icon > .plate, .dyn-mob-btn > .dyn-box, .dyn-fold-anchor > .plate, .dot.on { transform: none !important; } } /* ── Empty dashboard welcome ── */ +/* Toned from the wallpaper by label-contrast.js, never by the theme. */ +:root { + --label-color-2: rgba(235,235,245,.70); + --label-color-2-dark: rgba(60,60,67,.85); + --label-link: #00D2E0; + --label-link-dark: #00587E; +} .empty-state { position: fixed; inset: 0; z-index: 300; display: flex; flex-direction: column; @@ -855,15 +880,15 @@ .empty-state::before, .empty-state::after { content: ''; flex: 1 0 0 } .empty-state-mark { width: clamp(71px, 11vh, 112px); height: clamp(71px, 11vh, 112px); - color: var(--label-primary); + color: var(--label-color); margin-block-end: var(--sp-32); } .empty-state-title { - margin: 0; font-weight: 600; color: var(--label-primary); + margin: 0; font-weight: 600; color: var(--label-color); text-shadow: var(--label-shadow); font-size:var(--fs-title-2);line-height:var(--lh-title-2);letter-spacing:var(--tr-title-2); } .empty-state-sub { - margin: 0; max-width: 38ch; color: var(--label-secondary); font-weight: 500; + margin: 0; max-width: 38ch; color: var(--label-color-2); text-shadow: var(--label-shadow); font-weight: 500; font-size:var(--fs-title-2);line-height:var(--lh-title-2);letter-spacing:var(--tr-title-2); } .empty-state-btn { @@ -871,11 +896,18 @@ border-radius: 999px; min-height: 52px; background: var(--glass-surface-bg); border: .5px solid rgba(255,255,255,.22); -webkit-backdrop-filter: var(--glass-surface-blur); backdrop-filter: var(--glass-surface-blur); - color: var(--label-primary); text-decoration: none; font-weight: 500; + color: var(--label-color); text-decoration: none; font-weight: 500; font-size:var(--fs-body);line-height:var(--lh-body);letter-spacing:var(--tr-body); } +.empty-state-mark[data-tone="dark"] { color: var(--label-color-dark) } +.empty-state-title[data-tone="dark"] { color: var(--label-color-dark); text-shadow: var(--label-shadow-dark) } +.empty-state-sub[data-tone="dark"] { color: var(--label-color-2-dark); text-shadow: var(--label-shadow-dark) } +.empty-state-hint[data-tone="dark"] { color: var(--label-link-dark) } +.empty-state-btn[data-tone="dark"] { + background: rgba(0,0,0,.06); border-color: rgba(0,0,0,.12); color: var(--label-color-dark); +} .empty-state-hint { - margin-block-start: var(--sp-20); color: var(--accent); text-decoration: none; + margin-block-start: var(--sp-20); color: var(--label-link); text-decoration: none; font-size:var(--fs-footnote);line-height:var(--lh-footnote);letter-spacing:var(--tr-footnote); } .empty-state-hint:hover { text-decoration: underline } @@ -947,6 +979,8 @@ body.is-mob .spot-field:focus-within { outline:2px solid var(--accent); outline- .ilabel[data-tone="dark"], .dyn-mob-label[data-tone="dark"], .dyn-fold-label[data-tone="dark"] { color:#000000; text-shadow:0 0 2px rgba(255,255,255,.95); } + .empty-state-mark, .empty-state-title, .empty-state-btn { color:#FFFFFF; } + .empty-state-mark[data-tone="dark"], .empty-state-title[data-tone="dark"], .empty-state-btn[data-tone="dark"] { color:#000000; } .srn { color:var(--label-primary); } .srh, .sr-section { color:rgba(235,235,245,.85); } .widget, .mob-widget-card { border-color:rgba(255,255,255,.35); } @@ -1066,10 +1100,9 @@ html[data-theme="light"] .setup-bars .pwbar { background: rgba(0,0,0,.1); } .mp.on { opacity:1; transition:opacity .14s ease, - transform .19s cubic-bezier(.22,1,.36,1), - width .19s cubic-bezier(.22,1,.36,1), - height .19s cubic-bezier(.22,1,.36,1); + transform .19s cubic-bezier(.22,1,.36,1); } +.mp.mp-snap { transition:opacity .14s ease } .fh-hl { background:rgba(255,255,255,.07) } @media (prefers-reduced-motion: reduce) { .mp, .mp.on { transition:none } @@ -1146,12 +1179,9 @@ html[data-theme="light"] .folder-box-desktop { box-shadow:0 32px 80px rgba(0,0,0 html[data-theme="light"] .folder-dot { background:rgba(0,0,0,.25) } html[data-theme="light"] .folder-dot.on { background:rgba(0,0,0,.8) } html[data-theme="light"] .dyn-fold-inner-label { color:rgba(0,0,0,.88); text-shadow:0 0 2px rgba(255,255,255,.55) } -html[data-theme="light"] .empty-state-btn, html[data-theme="light"] .api-error-btn { background:rgba(0,0,0,.06); border-color:rgba(0,0,0,.12) } html[data-theme="light"] .api-error-screen { color:rgba(0,0,0,.7) } html[data-theme="light"] .api-error-sub { color:rgba(0,0,0,.6) } -html[data-theme="light"] .empty-state-sub { color:rgba(60,60,67,.85) } -html[data-theme="light"] .empty-state-hint { color:var(--accent-strong) } html[data-theme="light"] .folder-overlay .ilabel { color:var(--label-color-dark); text-shadow:var(--label-shadow-dark) } html[data-theme="light"] #dock:not(.mdock) { border-radius:38px; box-shadow:var(--glass-lift) } html[data-theme="light"] .folder-icon-grid, diff --git a/ui/css/tokens.css b/ui/css/tokens.css index e2e44199..21231e67 100644 --- a/ui/css/tokens.css +++ b/ui/css/tokens.css @@ -250,11 +250,11 @@ html.type-small:not(.is-mobile) { suit any page belong here. A selector naming dashboard elements, or an override of a token dashboard.css defines, stays in that file. */ -/* Not zero. A duration of exactly 0 skips the transitionend event some code - waits on. .01ms is instant and still fires. */ +/* Fades and colour changes keep their timing. Movement jumps. transitionend + never fires for a dropped property, so code waiting on it needs a timeout. */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { - transition-duration: .01ms !important; + transition-property: opacity, visibility, color, background-color, border-color, outline-color, fill, stroke, box-shadow !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; diff --git a/ui/index.html b/ui/index.html index e3a5c3ff..3dc7f49e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -25,8 +25,8 @@ Stackyard - - + + @@ -56,5 +56,5 @@ - + diff --git a/ui/js/admin-app-form.js b/ui/js/admin-app-form.js index f25a5c49..a072803c 100644 --- a/ui/js/admin-app-form.js +++ b/ui/js/admin-app-form.js @@ -1,4 +1,4 @@ -import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=cfee1271'; +import { clr, el, focusFirst, initial, inp as inpById, isolate, q as qSel, qa, qi, tgt } from '/js/utils.js?v=017012d7'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { loadLocalIcons, @@ -33,12 +33,12 @@ import { reveal, setTogDisabled, swapContent, -} from '/js/admin-shared.js?v=6538c84c'; -import { createListbox } from '/js/listbox.js?v=99ce62fa'; +} from '/js/admin-shared.js?v=c509d11e'; +import { createListbox } from '/js/listbox.js?v=2734f4e0'; import { MAX_LABELS } from '/js/badge-logic.js?v=85b1bc96'; -import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=aade667a'; +import { renderColorControl, BADGE_DEFAULT } from '/js/admin-color-control.js?v=b3bb1707'; import { badgeErrorAdvice, TONE } from '/js/admin-error.js?v=1c258dc1'; -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=8b4aadd4'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=eb6cc81c'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; export function buildFolderForm(body, item) { diff --git a/ui/js/admin-auth.js b/ui/js/admin-auth.js index 9eb0dee8..51a0b67d 100644 --- a/ui/js/admin-auth.js +++ b/ui/js/admin-auth.js @@ -1,8 +1,8 @@ -import { apiGet, apiPost } from '/js/admin-shared.js?v=6538c84c'; +import { apiGet, apiPost } from '/js/admin-shared.js?v=c509d11e'; import { initI18n, t } from '/js/i18n.js?v=ed7c5565'; import { loginErrorKey } from '/js/admin-error.js?v=1c258dc1'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; -import { el, inp as inpById, qa } from '/js/utils.js?v=cfee1271'; +import { el, inp as inpById, qa } from '/js/utils.js?v=017012d7'; import { blockingScreenFor } from '/js/config-recovery.js?v=85138697'; export async function checkAuth(onLogin) { diff --git a/ui/js/admin-color-control.js b/ui/js/admin-color-control.js index 9bc5a2b1..85faa92e 100644 --- a/ui/js/admin-color-control.js +++ b/ui/js/admin-color-control.js @@ -1,9 +1,9 @@ -import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=6538c84c'; +import { PE_SVG, initInlineEdit, toast, reveal } from '/js/admin-shared.js?v=c509d11e'; import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa, q } from '/js/utils.js?v=cfee1271'; +import { qa, q } from '/js/utils.js?v=017012d7'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; -import { createListbox } from '/js/listbox.js?v=99ce62fa'; +import { createListbox } from '/js/listbox.js?v=2734f4e0'; import { HUE_NAMES, ROLE_NAMES, TILE_KEYWORDS, pageTheme, tileColor } from '/js/palette.js?v=3fb8ae43'; const CC_SWATCHES = ['#1c1c1e', '#8e8e93', '#f2f2f7', '#ff393c', '#ffcd00', '#35c759', '#0289ff', '#cb30df']; diff --git a/ui/js/admin-drag.js b/ui/js/admin-drag.js index e57730e4..554a59fd 100644 --- a/ui/js/admin-drag.js +++ b/ui/js/admin-drag.js @@ -19,7 +19,7 @@ import { import { DOCK_MAX } from '/js/limits.js?v=ea61a441'; import { state } from '/js/admin-state.js?v=064b9b85'; import { snapshotItems } from '/js/admin-save-logic.js?v=b83791fb'; -import { qa } from '/js/utils.js?v=cfee1271'; +import { qa } from '/js/utils.js?v=017012d7'; /** @type {(before: unknown) => void} */ let _save = () => {}; diff --git a/ui/js/admin-list.js b/ui/js/admin-list.js index 108db234..88aa67d7 100644 --- a/ui/js/admin-list.js +++ b/ui/js/admin-list.js @@ -15,14 +15,14 @@ import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; import { snapshotItems } from '/js/admin-save-logic.js?v=b83791fb'; import { isShownChild, reorderItems, shownChildren, topLevelRows } from '/js/admin-logic.js?v=28471483'; import { listGroup } from '/js/admin-drag-logic.js?v=4aae5094'; -import { initDrag, redrawHeld, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=75dfcde2'; +import { initDrag, redrawHeld, wireDockZone, wireRowDrag } from '/js/admin-drag.js?v=e2c9c061'; import { DOCK_MAX } from '/js/limits.js?v=ea61a441'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; -import { paintIcon, toast } from '/js/admin-shared.js?v=6538c84c'; -import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=cfee1271'; +import { paintIcon, toast } from '/js/admin-shared.js?v=c509d11e'; +import { clr, el, focusFirst, initial, qa, setUserText } from '/js/utils.js?v=017012d7'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { sizeLabel } from '/js/admin-widget-form.js?v=4e0deae8'; +import { sizeLabel } from '/js/admin-widget-form.js?v=e9db6e2a'; import { widgetGlyph } from '/js/widget-glyphs.js?v=479a53ee'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index e2433c85..b8268684 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -1,4 +1,4 @@ -import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=6538c84c'; +import { toast, apiGet, apiPost, errorText, reveal, swapContent, wireInlineEdit } from '/js/admin-shared.js?v=c509d11e'; import { socketProbeAdvice } from '/js/admin-error.js?v=1c258dc1'; import { pwStrength } from '/js/password-strength.js?v=389e0ed0'; import { t } from '/js/i18n.js?v=ed7c5565'; @@ -11,11 +11,11 @@ import { BLOCK, } from '/js/admin-logic.js?v=28471483'; import { confirmText, promptModal } from '/js/modal.js?v=6b0320bd'; -import { el, inp, setUserText } from '/js/utils.js?v=cfee1271'; +import { el, inp, setUserText } from '/js/utils.js?v=017012d7'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { serialWrites } from '/js/admin-save-logic.js?v=b83791fb'; -import { renderColorControl } from '/js/admin-color-control.js?v=aade667a'; -import { BACKDROP } from '/js/background.js?v=72db97ac'; +import { renderColorControl } from '/js/admin-color-control.js?v=b3bb1707'; +import { BACKDROP } from '/js/background.js?v=4d651887'; import { ALLOWED_HOSTS_MAX, firstBadHost, diff --git a/ui/js/admin-shared.js b/ui/js/admin-shared.js index d7c08b7a..ed926c13 100644 --- a/ui/js/admin-shared.js +++ b/ui/js/admin-shared.js @@ -1,6 +1,6 @@ /* Stateless helpers shared by the admin modules. Mutable state stays out. */ import { recoversSession, toastHoldMs } from '/js/admin-logic.js?v=28471483'; -import { el, isolate, q } from '/js/utils.js?v=cfee1271'; +import { el, isolate, q } from '/js/utils.js?v=017012d7'; import { t } from '/js/i18n.js?v=ed7c5565'; import { errorAdvice } from '/js/admin-error.js?v=1c258dc1'; import { formatList, formatNumber } from '/js/format-number.js?v=abcd4ae4'; diff --git a/ui/js/admin-widget-form.js b/ui/js/admin-widget-form.js index 4978c00c..c78fce8b 100644 --- a/ui/js/admin-widget-form.js +++ b/ui/js/admin-widget-form.js @@ -1,11 +1,11 @@ import { state } from '/js/admin-state.js?v=064b9b85'; -import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=6538c84c'; -import { createListbox } from '/js/listbox.js?v=99ce62fa'; -import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=fcc1d192'; +import { PE_SVG, initInlineEdit, swapContent } from '/js/admin-shared.js?v=c509d11e'; +import { createListbox } from '/js/listbox.js?v=2734f4e0'; +import { renderWidgetConfigForm } from '/js/widget-config-form.js?v=e978f0b5'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { sizesForView, widgetConfigMode, rejectionLines, carriesTypedValues } from '/js/admin-logic.js?v=28471483'; import { t } from '/js/i18n.js?v=ed7c5565'; -import { focusFirst, q, qi, qa } from '/js/utils.js?v=cfee1271'; +import { focusFirst, q, qi, qa } from '/js/utils.js?v=017012d7'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; const SIZES_WITH_ICONS = new Set(['small', 'medium', 'large', 'xlarge']); diff --git a/ui/js/admin.js b/ui/js/admin.js index 96bb676c..017dc869 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -4,10 +4,10 @@ import { buildSettingsTileForm, captureActLabels, serializeKvRows, -} from '/js/admin-app-form.js?v=9a378086'; -import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=2abe4da5'; +} from '/js/admin-app-form.js?v=2ede5bc6'; +import { checkAuth, requireLogin, wirePasswordStrength } from '/js/admin-auth.js?v=d4c794a0'; import { recoveryShown } from '/js/config-recovery.js?v=85138697'; -import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=09a21031'; +import { focusRow, initList, render, syncFilterUI } from '/js/admin-list.js?v=b7cfd03d'; import { resolveAdminSection, settleDock } from '/js/admin-logic.js?v=28471483'; import { afterImport, @@ -31,7 +31,7 @@ import { wireUnsplashKey, wireProxmox, applyHealthChecks, -} from '/js/admin-settings.js?v=f72a7ec0'; +} from '/js/admin-settings.js?v=61b1ea1d'; import { apiGet, apiPost, @@ -45,13 +45,13 @@ import { setReauthHandler, toast, withheldText, -} from '/js/admin-shared.js?v=6538c84c'; +} from '/js/admin-shared.js?v=c509d11e'; import { collapsedFolders, filter, state } from '/js/admin-state.js?v=064b9b85'; -import { buildWidgetForm } from '/js/admin-widget-form.js?v=4e0deae8'; -import { initFluidHover } from '/js/fluid-hover.js?v=8b4aadd4'; +import { buildWidgetForm } from '/js/admin-widget-form.js?v=e9db6e2a'; +import { initFluidHover } from '/js/fluid-hover.js?v=eb6cc81c'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { initGlideSelect, syncGlideSelect } from '/js/glide-select.js?v=75a71ab4'; -import { createListbox } from '/js/listbox.js?v=99ce62fa'; +import { createListbox } from '/js/listbox.js?v=2734f4e0'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; import { initI18n, LANGUAGES, t } from '/js/i18n.js?v=ed7c5565'; import { loadLocalIcons } from '/js/icons.js?v=5ac01d72'; @@ -92,8 +92,8 @@ import { storeGet, storeSet, tgt, -} from '/js/utils.js?v=cfee1271'; -import { applyBackground, resolveBackground } from '/js/background.js?v=72db97ac'; +} from '/js/utils.js?v=017012d7'; +import { applyBackground, resolveBackground } from '/js/background.js?v=4d651887'; import { parseYamlTolerant, YamlLiteError, YamlTooLargeError } from '/js/yaml-lite.js?v=a42ef914'; ensureSprite(); diff --git a/ui/js/background.js b/ui/js/background.js index 45680ae1..c1cb427d 100644 --- a/ui/js/background.js +++ b/ui/js/background.js @@ -1,5 +1,5 @@ // @ts-check -import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=cfee1271'; +import { cssColor, sanitizeCssUrl } from '/js/utils.js?v=017012d7'; import { loadWallpaper, saveWallpaper } from '/js/wallpaper-cache.js?v=c5f8a3e6'; export const BACKDROP = '#0d1117'; diff --git a/ui/js/dashboard-logic.js b/ui/js/dashboard-logic.js index 25708de5..22ef5799 100644 --- a/ui/js/dashboard-logic.js +++ b/ui/js/dashboard-logic.js @@ -126,3 +126,51 @@ export function wheelPager({ threshold = 40, quietMs = 220 } = {}) { return sum > 0 ? 1 : -1; }; } + +export const FLICK_SPEED = 0.11; +export const SWIPE_LOCK_PX = 10; +const PAGE_EASE_SLOPE = 0.72 / 0.32; +export const PAGE_TURN_MS = 280; + +/** @returns {{ add: (x: number, t: number) => void, speed: (now: number) => number }} */ +export function fingerSpeed(windowMs = 100) { + /** @type {[number, number][]} */ + let pts = []; + return { + add(x, t) { + pts.push([x, t]); + pts = pts.filter(p => t - p[1] <= windowMs); + }, + speed(now) { + if (pts.length < 2 || now - pts[pts.length - 1][1] > windowMs) return 0; + const [x0, t0] = pts[0]; + const [x1, t1] = pts[pts.length - 1]; + return t1 > t0 ? (x1 - x0) / (t1 - t0) : 0; + }, + }; +} + +/** @param {number} over @param {number} dim @returns {number} */ +export function rubberBand(over, dim) { + return (over * 0.55 * dim) / (dim + 0.55 * over); +} + +/** `offset` and `speed` are physical: negative is towards the left of the + screen. `dir` is pageDir(), so the next page is on the left in one direction + and on the right in the other. + + @param {{ page: number, total: number, offset: number, speed: number, width: number, dir: number }} o + @returns {number} the page index */ +export function swipeSettle({ page, total, offset, speed, width, dir }) { + const at = page + (-offset * dir) / width; + const along = -speed * dir; + let target = Math.round(at); + if (Math.abs(speed) > FLICK_SPEED) target = along > 0 ? Math.ceil(at) : Math.floor(at); + return Math.max(0, Math.min(total - 1, target)); +} + +/** @param {number} distance @param {number} speed @returns {number} ms */ +export function settleMs(distance, speed) { + if (!(speed > 0) || !(distance > 0)) return PAGE_TURN_MS; + return Math.round(Math.max(120, Math.min(PAGE_TURN_MS, (PAGE_EASE_SLOPE * distance) / speed))); +} diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index 99408e5d..7c05eb98 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -23,14 +23,14 @@ import { qa, qi, scrollsSideways, - setUserText, + setCentredText, storeGet, storeSet, teardownWidgets, titleWhenTruncated, -} from '/js/utils.js?v=cfee1271'; -import { initFluidHover } from '/js/fluid-hover.js?v=8b4aadd4'; -import { initSpotlight } from '/js/spotlight.js?v=06e7ef30'; +} from '/js/utils.js?v=017012d7'; +import { initFluidHover } from '/js/fluid-hover.js?v=eb6cc81c'; +import { initSpotlight } from '/js/spotlight.js?v=73fdaf2e'; import { html, setHtml, raw } from '/js/html.js?v=c71f8903'; import { initI18n, t, currentLang } from '/js/i18n.js?v=ed7c5565'; import { blockingScreenFor, showBlockingScreen } from '/js/config-recovery.js?v=85138697'; @@ -46,7 +46,8 @@ import { buildMobile, resetMobileChrome, mkFolderGlyph, -} from '/js/ui.js?v=43203089'; + swipePager, +} from '/js/ui.js?v=92aeff53'; import { badgeMinimum, badgeSignature, computeBadgeVisual, readBadgeUpdate } from '/js/badge-logic.js?v=85b1bc96'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { closeBadgePopover, wireBadgePopover } from '/js/badge-popover.js?v=23793736'; @@ -58,12 +59,12 @@ import { landingAfterSetup, restorePage, wheelPager, -} from '/js/dashboard-logic.js?v=6a29cedc'; -import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=72db97ac'; +} from '/js/dashboard-logic.js?v=5fa66027'; +import { applyBackground, BACKDROP, resolveBackground } from '/js/background.js?v=4d651887'; import { repeatJittered } from '/js/jitter.js?v=087a1fcf'; import { isMobileLayout, onLayoutChange } from '/js/layout.js?v=e9f4b607'; import { startWakeLock } from '/js/wake-lock.js?v=6b9591cf'; -import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; +import { applyLabelTones, loadSamplingImage, sampleImage, toneForColor } from '/js/label-contrast.js?v=4e97555e'; import { ensureSprite, iconSvg } from '/js/icon-set.js?v=26655aca'; import { pageTheme, paletteColor } from '/js/palette.js?v=3fb8ae43'; import { THEME_KEY, applyTheme, prefersDark, readMode, resolveTheme } from '/js/theme.js?v=eeafa4b5'; @@ -126,7 +127,7 @@ function desktopRows(hasDock) { return Math.max(1, Math.min(4, Math.floor((ih - top - bottom + gm.rowGap) / (gm.tile + gm.rowGap)))); } -const CB = { spotOpen: null, spotClose: null, mobPillBump: null }; +const CB = { spotOpen: null, spotClose: null, mobPillBump: null, homeSwipe: null }; /* A touch produces a compatibility mouse event after it. */ const COMPAT_POINTER_MS = 500; @@ -323,9 +324,8 @@ function mkIcon(item) { if (showLabel) { const l = mk('div'); l.className = 'ilabel'; - l.dir = 'auto'; l.style.width = iw + 12 + 'px'; - setUserText(l, item.label || item.id); + setCentredText(l, item.label || item.id); a.appendChild(l); } return a; @@ -915,9 +915,9 @@ async function boot() { what a narrow window gets, and there its own swipe is the only pager, so a mouse and a keyboard cannot leave page one. */ document.addEventListener('keydown', e => { - if (el('spot').classList.contains('on')) return; + if (el('spot').hasAttribute('open')) return; /* The overlay covers the page it would scroll behind. */ - if (document.querySelector('.folder-overlay')) return; + if (document.querySelector('.folder-overlay[open]')) return; if (e.key === 'ArrowRight') goTo(pg + pageDir()); if (e.key === 'ArrowLeft') goTo(pg - pageDir()); }); @@ -928,8 +928,8 @@ async function boot() { 'wheel', e => { if (e.ctrlKey) return; - if (el('spot').classList.contains('on')) return; - if (document.querySelector('.folder-overlay, .folder-overlay-mobile')) return; + if (el('spot').hasAttribute('open')) return; + if (document.querySelector('.folder-overlay[open], .folder-overlay-mobile[open]')) return; const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerWidth : 1; const dx = e.deltaX * unit, dy = e.deltaY * unit; @@ -959,27 +959,30 @@ async function boot() { const dx = e.clientX - _dMx; if (Math.abs(dx) > 60) goTo(pg + (dx < 0 ? 1 : -1) * pageDir()); }); - let _dTx = 0; document.addEventListener( 'touchstart', - e => { - _dTx = e.touches[0].clientX; + () => { _lastTouch = Date.now(); }, { passive: true }, ); - /* The mobile layout has its own swipe, in ui.js. Both would advance two - pages for one gesture. */ document.addEventListener( 'touchend', - e => { + () => { _lastTouch = Date.now(); - if (MOB) return; - const dx = e.changedTouches[0].clientX - _dTx; - if (Math.abs(dx) > 50) goTo(pg + (dx < 0 ? 1 : -1) * pageDir()); }, { passive: true }, ); + const strip = el('pages'); + CB.homeSwipe = swipePager(document, strip, { + width: () => innerWidth, + page: () => pg, + total: () => strip.children.length, + turn: n => goTo(n), + enabled: () => + !el('spot').hasAttribute('open') && + !document.querySelector('.folder-overlay[open], .folder-overlay-mobile[open]'), + }); applyBg(); @@ -1104,7 +1107,9 @@ async function boot() { /* Keep. Without it Safari leaves the tile scaled after a new tab opens. */ document.addEventListener('click', e => { if (!(e.target instanceof Element)) return; - const a = e.target.closest('a[target="_blank"]:is(.icon, .di, .folder-icon-link)'); + const a = e.target.closest( + 'a[target="_blank"]:is(.icon, .di, .folder-icon-link, .dyn-mob-icon, .dyn-dock-icon, .dyn-fold-anchor)', + ); if (!a) return; a.classList.add('settled'); a.addEventListener('pointerleave', () => a.classList.remove('settled'), { once: true }); diff --git a/ui/js/fluid-hover.js b/ui/js/fluid-hover.js index de80972b..20130c09 100644 --- a/ui/js/fluid-hover.js +++ b/ui/js/fluid-hover.js @@ -41,7 +41,7 @@ function stateFor(container) { return s; } -function refresh(container) { +function refresh(container, snap = false) { const s = stateFor(container); /* Whichever moved last. The pointer holding it meant a keyboard cursor in a hovered list had no highlight at all, because the row's own background is @@ -49,6 +49,7 @@ function refresh(container) { const target = s.hover || s.kb; if (target && target.isConnected && selectable(target)) { const pill = ensurePill(container, 'fh-hl'); + pill.classList.toggle('mp-snap', snap); live.add(container); placePill(container, pill, target, s.zoom ? s.zoom() : 1); } else { @@ -88,7 +89,7 @@ export function fluidHoverKb(item) { s.hover = null; s.kb = item; bind(container); - refresh(container); + refresh(container, true); } /* For the test: proves a detached container is not retained. */ diff --git a/ui/js/label-contrast.js b/ui/js/label-contrast.js index d79c6d4b..34bde3de 100644 --- a/ui/js/label-contrast.js +++ b/ui/js/label-contrast.js @@ -234,8 +234,8 @@ export function loadSamplingImage(url) { } /* The page indicator is chrome over the same wallpaper, and it is tinted rather - than inked, so it needs the same reading. */ -const LABEL_SELECTOR = '.ilabel, .dyn-mob-label, .dyn-fold-label, #dots'; + than inked, so it needs the same reading. So does the empty dashboard's text. */ +const LABEL_SELECTOR = '.ilabel, .dyn-mob-label, .dyn-fold-label, #dots, .empty-state > *'; /* A folder overlay lays its own scrim over the background. */ const SCRIM = '.folder-overlay, .folder-overlay-mobile'; diff --git a/ui/js/listbox.js b/ui/js/listbox.js index 3233f0c5..1a01bf7b 100644 --- a/ui/js/listbox.js +++ b/ui/js/listbox.js @@ -7,10 +7,10 @@ which exposes a strip of page canvas and misplaces the menu. */ import { nextActiveIndex, singleChoice } from '/js/admin-logic.js?v=28471483'; -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=8b4aadd4'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=eb6cc81c'; import { formatList } from '/js/format-number.js?v=abcd4ae4'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { qa } from '/js/utils.js?v=cfee1271'; +import { qa } from '/js/utils.js?v=017012d7'; import { iconSvg } from '/js/icon-set.js?v=26655aca'; const CHEV = iconSvg('chevrons', 22, 'solid', 'dd-chev'); diff --git a/ui/js/spotlight.js b/ui/js/spotlight.js index 9074a7df..c4a124e2 100644 --- a/ui/js/spotlight.js +++ b/ui/js/spotlight.js @@ -1,5 +1,5 @@ -import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=8b4aadd4'; -import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=cfee1271'; +import { fluidHoverClear, fluidHoverKb } from '/js/fluid-hover.js?v=eb6cc81c'; +import { mk, clr, el, initial, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=017012d7'; import { t } from '/js/i18n.js?v=ed7c5565'; /* Attached to the window so a re-open can undo the previous one. */ @@ -28,7 +28,8 @@ export function initSpotlight({ let si = 0, cur = [], returnFocus = null, - openGen = 0; + openGen = 0, + closeGen = 0; inp.setAttribute('role', 'combobox'); inp.setAttribute('aria-autocomplete', 'list'); @@ -96,6 +97,7 @@ export function initSpotlight({ const ic = mk('div'); ic.className = 'sri'; + ic.dir = 'ltr'; ic.style.background = clr(app.color); if (isFolder && folderGlyph) { ic.style.background = 'transparent'; @@ -223,13 +225,11 @@ export function initSpotlight({ close(); }); - /* The dialog stays open for the length of the fade, so the page behind it - is inert until the overlay has actually gone. The browser returns focus - when it closes. */ + /* The dialog closes at once, so the page takes input during the fade. The + overlay stays drawn until the fade ends and lets input through. */ ov.addEventListener('close', () => { - ov.classList.remove('on'); - inp.value = ''; - res.replaceChildren(); + /* A close that lands after a reopen would move focus out of the new field. */ + if (closeGen !== openGen) return; const back = returnFocus; returnFocus = null; /* Only while focus is still inside the closed dialog. A folder opened from @@ -241,7 +241,7 @@ export function initSpotlight({ function close() { if (!ov.open) return; - openGen++; + closeGen = ++openGen; ov.classList.remove('vis'); inp.setAttribute('aria-expanded', 'false'); inp.setAttribute('aria-activedescendant', ''); @@ -252,9 +252,14 @@ export function initSpotlight({ ov.style.top = ''; ov.style.bottom = ''; ov.classList.remove('kb'); + ov.close(); + const gen = openGen; setTimeout(() => { - if (!ov.classList.contains('vis')) ov.close(); - }, 220); + if (gen !== openGen) return; + ov.classList.remove('on'); + inp.value = ''; + res.replaceChildren(); + }, 160); } ov.onclick = e => { @@ -315,7 +320,7 @@ export function initSpotlight({ document.addEventListener( 'keydown', e => { - if (ov.classList.contains('on') || !typeToOpen()) return; + if (ov.open || !typeToOpen()) return; if (e.key === 'Escape' || e.key.length !== 1 || e.metaKey || e.ctrlKey || e.altKey) return; if (e.key === ' ' && e.target instanceof Element && e.target.closest(ACTIVATES_ON_SPACE)) return; e.stopImmediatePropagation(); diff --git a/ui/js/ui.js b/ui/js/ui.js index 1d6631ed..d6a0d632 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -15,13 +15,15 @@ import { el, q, qa, + setCentredText, setUserText, -} from '/js/utils.js?v=cfee1271'; +} from '/js/utils.js?v=017012d7'; import { t, currentLang } from '/js/i18n.js?v=ed7c5565'; -import { toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; +import { toneForColor } from '/js/label-contrast.js?v=4e97555e'; import { mobileMetrics, gridColumnWidth, gridCellCount } from '/js/mobile-metrics.js?v=349f4300'; import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1'; import { mkGlassRim, observeGlass } from '/js/glass-rim.js?v=3faec233'; +import { fingerSpeed, rubberBand, settleMs, swipeSettle, SWIPE_LOCK_PX } from '/js/dashboard-logic.js?v=5fa66027'; const PHONE_W = 393; @@ -36,7 +38,6 @@ const breg = (...a) => _state.breg(...a); const bunreg = (...a) => _state.bunreg(...a); const bupd = (...a) => _state.bupd(...a); const BEL = () => _state.BEL; -const goTo = (...a) => _state.goTo(...a); const CB = () => _state.CB; const st = () => _state; const widgetReg = () => _state?.widgetReg || Object.create(null); @@ -90,20 +91,15 @@ function mkMiniIcon(child, pointerEvents) { }; img.src = srcs[0]; bg.appendChild(img); - } else { - const s = mk('span'); - s.className = toneForColor(plate) === 'dark' ? 'folder-mini-fb fb-on-light' : 'folder-mini-fb'; - if (pointerEvents === 'none') s.style.pointerEvents = 'none'; - s.textContent = initial(child.label); - bg.appendChild(s); + return bg; } - } else { - const s = mk('span'); - s.className = toneForColor(plate) === 'dark' ? 'folder-mini-fb fb-on-light' : 'folder-mini-fb'; - if (pointerEvents === 'none') s.style.pointerEvents = 'none'; - s.textContent = initial(child.label); - bg.appendChild(s); } + const s = mk('span'); + s.className = toneForColor(plate) === 'dark' ? 'folder-mini-fb fb-on-light' : 'folder-mini-fb'; + s.dir = 'ltr'; + if (pointerEvents === 'none') s.style.pointerEvents = 'none'; + s.textContent = initial(child.label); + bg.appendChild(s); return bg; } @@ -139,7 +135,7 @@ export function mkFolder(item) { a.dataset.tileName = folderName(item); if (!showLabel) a.title = item.label || t('type.folder'); a.onclick = () => { - openFolderDesktop(item); + openFolderDesktop(item, a); }; const box = mk('div'); box.className = 'dyn-folder-box'; @@ -166,16 +162,230 @@ export function mkFolder(item) { if (showLabel) { const l = mk('div'); l.className = 'ilabel'; - l.dir = 'auto'; l.style.width = iw + 12 + 'px'; - setUserText(l, item.label || t('type.folder')); + setCentredText(l, item.label || t('type.folder')); a.appendChild(l); } return a; } +/** Runs `fn` on a tap or a click. A touch that moved the pager's lock + distance is a swipe and runs nothing. + @param {HTMLElement} node @param {() => void} fn */ +export function onTap(node, fn) { + let x0 = 0, + y0 = 0, + moved = false; + node.addEventListener( + 'touchstart', + /** @param {TouchEvent} e */ e => { + const touch = e.changedTouches[0]; + x0 = touch.clientX; + y0 = touch.clientY; + moved = e.touches.length > 1; + }, + { passive: true }, + ); + node.addEventListener( + 'touchmove', + /** @param {TouchEvent} e */ e => { + const touch = e.changedTouches[0]; + if (touch && Math.hypot(touch.clientX - x0, touch.clientY - y0) >= SWIPE_LOCK_PX) moved = true; + }, + { passive: true }, + ); + node.addEventListener( + 'touchend', + /** @param {TouchEvent} e */ e => { + /* Do not stop a swipe. The home pager never sees the lift and the strip stays between pages. */ + if (moved) return; + e.preventDefault(); + e.stopPropagation(); + fn(); + }, + { passive: false }, + ); + node.addEventListener('click', () => { + if (moved) moved = false; + else fn(); + }); +} + +/** @typedef {{ down: (id: number|string, x: number, y: number, t: number) => void, + move: (id: number|string, x: number, y: number, t: number) => void, + up: (id: number|string, t: number, fling: boolean) => void, + cancel: () => void }} SwipeFeed */ + +/** The returned handle takes touches from elsewhere, such as a widget frame, + in this page's coordinates. + + @param {EventTarget|null} target where the touches land + @param {HTMLElement} strip + @param {{ width: () => number, page: () => number, total: () => number, + turn: (n: number) => void, enabled?: () => boolean }} o + @returns {SwipeFeed} */ +export function swipePager(target, strip, o) { + /** @type {number|string|null} */ + let id = null; + let x0 = 0, + y0 = 0, + axis = '', + from = 0, + x = 0, + speed = fingerSpeed(); + /** @type {ReturnType|undefined} */ + let done; + const rest = n => -pageDir() * n * o.width(); + /** @type {SwipeFeed} */ + const feed = { + down(pid, px, py, ts) { + if (id !== null || (o.enabled && !o.enabled())) return; + id = pid; + x0 = px; + y0 = py; + axis = ''; + speed = fingerSpeed(); + speed.add(x0, ts); + }, + move(pid, px, py, ts) { + if (pid !== id || axis === 'y') return; + if (!axis) { + const dx = px - x0, + dy = py - y0; + if (Math.hypot(dx, dy) < SWIPE_LOCK_PX) return; + axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'; + if (axis === 'y') return; + clearTimeout(done); + const m = getComputedStyle(strip).transform; + from = m === 'none' ? 0 : new DOMMatrixReadOnly(m).m41; + strip.style.transition = 'none'; + strip.style.willChange = 'transform'; + strip.classList.add('dragging'); + } + speed.add(px, ts); + const ends = [rest(0), rest(Math.max(0, o.total() - 1))]; + const lo = Math.min(...ends), + hi = Math.max(...ends), + raw = from + px - x0; + x = raw > hi ? hi + rubberBand(raw - hi, o.width()) : raw < lo ? lo - rubberBand(lo - raw, o.width()) : raw; + strip.style.transform = strip.style.webkitTransform = `translateX(${x}px)`; + }, + up(pid, ts, fling) { + if (id === null || pid !== id) return; + release(fling ? speed.speed(ts) : 0, false); + }, + cancel() { + if (id !== null) release(0, true); + }, + }; + /** @param {number} v @param {boolean} stay */ + function release(v, stay) { + id = null; + if (axis !== 'x') return; + strip.classList.remove('dragging'); + const page = o.page(); + const n = stay + ? page + : swipeSettle({ + page, + total: o.total(), + offset: x - rest(page), + speed: v, + width: o.width(), + dir: pageDir(), + }); + const ms = settleMs(Math.abs(rest(n) - x), Math.sign(rest(n) - x) * v); + strip.style.transition = `transform ${ms}ms var(--page-ease)`; + o.turn(n); + done = setTimeout(() => { + strip.style.transition = ''; + }, ms); + } + if (!target) return feed; + /** @param {TouchEvent} e */ + const find = e => Array.prototype.find.call(e.changedTouches, p => p.identifier === id); + target.addEventListener( + 'touchstart', + /** @param {TouchEvent} e */ e => { + /* A handler that stops touchend leaves the old finger tracked forever. */ + if (id !== null && e.touches.length === e.changedTouches.length) feed.up(id, e.timeStamp, false); + if (e.touches.length > 1) { + feed.cancel(); + return; + } + const touch = e.changedTouches[0]; + feed.down(touch.identifier, touch.clientX, touch.clientY, e.timeStamp); + }, + { passive: true }, + ); + target.addEventListener( + 'touchmove', + /** @param {TouchEvent} e */ e => { + const touch = find(e); + if (touch) feed.move(touch.identifier, touch.clientX, touch.clientY, e.timeStamp); + }, + { passive: true }, + ); + /** @param {boolean} fling */ + const lift = fling => /** @param {TouchEvent} e */ e => { + const touch = find(e); + if (touch) feed.up(touch.identifier, e.timeStamp, fling); + }; + target.addEventListener('touchend', lift(true), { passive: true }); + target.addEventListener('touchcancel', lift(false), { passive: true }); + return feed; +} + +/** @param {HTMLDialogElement} ov @param {HTMLElement[]} parts + @param {Element} [tile] @returns {() => void} */ +function folderMotion(ov, parts, tile) { + const still = matchMedia('(prefers-reduced-motion: reduce)').matches; + const ease = 'cubic-bezier(.32,.72,0,1)'; + /** @param {HTMLElement} p */ + const intoTile = p => { + const from = !still && tile?.isConnected ? tile.getBoundingClientRect() : null; + if (!from?.width) return 'none'; + const r = p.getBoundingClientRect(); + const s = from.width / r.width; + const dx = from.left + from.width / 2 - r.left - (r.width * s) / 2; + const dy = from.top + from.height / 2 - r.top - (r.height * s) / 2; + return `translate(${dx}px, ${dy}px) scale(${s})`; + }; + for (const p of parts) p.style.transformOrigin = '0 0'; + const steps = [ + { el: ov, prop: 'opacity', shut: '0', open: '1' }, + ...parts.map(part => ({ el: part, prop: 'transform', shut: intoTile(part), open: 'none' })), + ]; + /** @param {string} prop @param {boolean} opening */ + const timing = (prop, opening) => + still + ? { duration: 150, easing: 'ease-out' } + : prop === 'opacity' + ? { duration: opening ? 120 : 250, easing: opening ? 'ease-out' : 'cubic-bezier(.6,0,1,1)' } + : { duration: opening ? 300 : 250, easing: ease }; + let running = steps.map(s => s.el.animate({ [s.prop]: [s.shut, s.open] }, timing(s.prop, true))); + return () => { + if (ov.contains(document.activeElement)) /** @type {HTMLElement} */ (document.activeElement).blur(); + const now = steps.map(s => getComputedStyle(s.el)[s.prop]); + steps.forEach((s, i) => s.el.style.setProperty(s.prop, now[i])); + running.forEach(a => a.cancel()); + ov.style.opacity = '1'; + running = steps + .slice(1) + .flatMap((s, i) => [ + s.el.animate({ transform: [now[i + 1], s.shut] }, { ...timing('transform', false), fill: 'forwards' }), + s.el.animate({ opacity: [now[0], 0] }, { ...timing('opacity', false), fill: 'forwards' }), + ]); + running[0].finished.then( + () => ov.remove(), + () => {}, + ); + }; +} + let folderOverlay = null; -export function openFolderDesktop(folder) { +/** @param {any} folder @param {Element} [tile] */ +export function openFolderDesktop(folder, tile) { if (folderOverlay) { folderOverlay.remove(); folderOverlay = null; @@ -213,9 +423,8 @@ export function openFolderDesktop(folder) { if (showLabel) { const l = mk('div'); l.className = 'ilabel'; - l.dir = 'auto'; l.style.width = iw + 12 + 'px'; - setUserText(l, child.label || child.id); + setCentredText(l, child.label || child.id); a.appendChild(l); } grid.appendChild(a); @@ -230,10 +439,11 @@ export function openFolderDesktop(folder) { /* Escape and the tile's own toggle arrive here alike, and a badge registered to an element that has gone keeps the dashboard repainting it. */ let stopGlass = () => {}; + let shrink = () => ov.remove(); ov.addEventListener('close', () => { stopGlass(); registeredBadges.forEach(badge => BEL().forEach((_, id) => bunreg(id, badge))); - ov.remove(); + shrink(); folderOverlay = null; }); /* The overlay is the scrim, so a click reported against it is a click @@ -249,6 +459,7 @@ export function openFolderDesktop(folder) { tiles under the scrim were reachable by Tab and by a screen reader. */ ov.showModal(); ov.focus(); + shrink = folderMotion(ov, [outer], tile); stopGlass = observeGlass(box, 28, 0.2); } @@ -268,7 +479,7 @@ function mFolder(item, cw, rh, isz, ir, im, sc) { setTimeout(() => { _opening = false; }, 500); - openFolderMobile(item, isz, ir, im, sc); + openFolderMobile(item, isz, ir, im, sc, a); } let _tStarted = false, _tMoved = false, @@ -324,9 +535,8 @@ function mFolder(item, cw, rh, isz, ir, im, sc) { if (showLabel) { const l = mk('div'); l.className = 'dyn-fold-label'; - l.dir = 'auto'; css(l, { '--lfs': Math.max(11, Math.round(12 * sc)) + 'px', '--lw': cw - 4 + 'px' }); - setUserText(l, item.label || t('type.folder')); + setCentredText(l, item.label || t('type.folder')); a.appendChild(l); } return a; @@ -335,7 +545,9 @@ function mFolder(item, cw, rh, isz, ir, im, sc) { let folderOverlayMob = null; /* The widest phone viewport. Matches the cap in mobile-metrics.js. */ export const FOLDER_MAX_VW = 430; -export function openFolderMobile(folder, isz, _ir, _im, sc) { +/** @param {any} folder @param {number} isz @param {number} _ir @param {number} _im + @param {number} sc @param {Element} [tile] */ +export function openFolderMobile(folder, isz, _ir, _im, sc, tile) { if (folderOverlayMob) { folderOverlayMob.remove(); folderOverlayMob = null; @@ -357,9 +569,10 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { } /* A badge registered to an element that has gone keeps the dashboard repainting it. */ + let shrink = () => ov.remove(); ov.addEventListener('close', () => { qa('.badge', ov).forEach(badge => BEL().forEach((_, id) => bunreg(id, badge))); - ov.remove(); + shrink(); folderOverlayMob = null; }); @@ -463,9 +676,8 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { if (showLabel) { const l = mk('div'); l.className = 'dyn-fold-inner-label'; - l.dir = 'auto'; css(l, { '--lfs': Math.max(11, Math.round(11 * ptScale)) + 'px', '--fiw': folderIconW + 'px' }); - setUserText(l, child.label || child.id); + setCentredText(l, child.label || child.id); a.appendChild(l); } p.appendChild(a); @@ -501,41 +713,10 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { if (dotsEl) box.appendChild(dotsEl); children.forEach(c => bupd(c.id)); - let tx0 = 0, - ty0 = 0, - swiping = false; - box.addEventListener( - 'touchstart', - e => { - tx0 = e.touches[0].clientX; - ty0 = e.touches[0].clientY; - swiping = false; - e.stopPropagation(); - }, - { passive: false }, - ); - box.addEventListener( - 'touchmove', - e => { - const dx = Math.abs(e.touches[0].clientX - tx0), - dy = Math.abs(e.touches[0].clientY - ty0); - if (dx > dy && dx > 8) swiping = true; - e.stopPropagation(); - e.preventDefault(); - }, - { passive: false }, - ); - box.addEventListener( - 'touchend', - e => { - e.stopPropagation(); - if (!swiping) return; - swiping = false; - const dx = e.changedTouches[0].clientX - tx0; - if (Math.abs(dx) > Math.round(30 * ptScale)) gotoPage(curPage + (dx < 0 ? 1 : -1) * pageDir()); - }, - { passive: false }, - ); + if (pages.length > 1) + swipePager(box, strip, { width: () => pageW, page: () => curPage, total: () => pages.length, turn: gotoPage }); + for (const type of ['touchstart', 'touchmove', 'touchend']) + box.addEventListener(type, e => e.stopPropagation(), { passive: true }); ov.appendChild(titleEl); ov.appendChild(box); ov.onclick = e => { @@ -558,6 +739,7 @@ export function openFolderMobile(folder, isz, _ir, _im, sc) { folderOverlayMob = ov; ov.showModal(); ov.focus(); + shrink = folderMotion(ov, [titleEl, box], tile); } /* Cells per widget size on the 4x6 home grid. */ @@ -718,9 +900,8 @@ export function buildMobile() { if (showLabel) { const l = mk('div'); l.className = 'dyn-mob-label'; - l.dir = 'auto'; css(l, { '--lfs': Math.max(11, Math.round(12 * sc)) + 'px', '--lw': '100%' }); - setUserText(l, item.label || item.id); + setCentredText(l, item.label || item.id); a.appendChild(l); } return a; @@ -750,7 +931,7 @@ export function buildMobile() { iframeOpts: item.iframe, overlayHref, mobile: true, - onSwipe: dir => goTo(st().pg + dir * pageDir()), + swipe: () => CB().homeSwipe, }); return card; } @@ -828,18 +1009,9 @@ export function buildMobile() { return d; }); pill.parentNode.replaceChild(pillNew, pill); - pillNew.addEventListener( - 'touchend', - e => { - e.preventDefault(); - e.stopPropagation(); - if (CB().spotOpen) CB().spotOpen(''); - }, - { passive: false }, - ); - pillNew.onclick = () => { + onTap(pillNew, () => { if (CB().spotOpen) CB().spotOpen(''); - }; + }); pillNew.onkeydown = e => { if (e.key !== 'Enter' && e.key !== ' ') return; e.preventDefault(); @@ -848,9 +1020,30 @@ export function buildMobile() { }; let _pillIdleTimer = null; + let _pillClipTimer = 0; + let pillShownW = pillSearchW; function pillPaging(on) { pillNew.classList.toggle('paging', on); - pillNew.style.width = (on ? pillDotsW : pillSearchW) + 'px'; + const to = on ? pillDotsW : pillSearchW; + const from = pillShownW; + pillShownW = to; + clearTimeout(_pillClipTimer); + if (from === to) return; + const wide = Math.max(from, to); + const inset = w => `inset(0 ${(wide - w) / 2}px round 999px)`; + pillNew.style.transition = 'none'; + pillNew.style.width = wide + 'px'; + pillNew.style.clipPath = inset(from); + pillNew.getBoundingClientRect(); + pillNew.style.transition = ''; + pillNew.style.clipPath = inset(to); + _pillClipTimer = setTimeout(() => { + pillNew.style.transition = 'none'; + pillNew.style.width = to + 'px'; + pillNew.style.clipPath = ''; + pillNew.getBoundingClientRect(); + pillNew.style.transition = ''; + }, 260); } function pillBump(newPg) { pillPaging(true); @@ -863,19 +1056,10 @@ export function buildMobile() { const { _mobTsCleanup, _mobTeCleanup } = st(); if (_mobTsCleanup) document.removeEventListener('touchstart', _mobTsCleanup); if (_mobTeCleanup) document.removeEventListener('touchend', _mobTeCleanup); - let tx = 0, - txOpenedWithFolder = false; - st()._mobTsCleanup = e => { - tx = e.touches[0].clientX; - txOpenedWithFolder = !!folderOverlayMob; - }; - st()._mobTeCleanup = e => { + st()._mobTsCleanup = null; + st()._mobTeCleanup = () => { clearMobWidgets(); /* tap on the home background dismisses any active sled */ - if (txOpenedWithFolder || folderOverlayMob) return; - const dx = e.changedTouches[0].clientX - tx; - if (Math.abs(dx) > 40) goTo(st().pg + (dx < 0 ? 1 : -1) * pageDir()); }; - document.addEventListener('touchstart', st()._mobTsCleanup, { passive: true }); document.addEventListener('touchend', st()._mobTeCleanup, { passive: true }); } diff --git a/ui/js/utils.js b/ui/js/utils.js index 461d1054..e71c5bd7 100644 --- a/ui/js/utils.js +++ b/ui/js/utils.js @@ -1,5 +1,5 @@ import { iconChain } from '/js/icons.js?v=5ac01d72'; -import { toneForColor } from '/js/label-contrast.js?v=0b1ebb19'; +import { toneForColor } from '/js/label-contrast.js?v=4e97555e'; import { SETTINGS_ICON, SETTINGS_ICON_LIGHT } from '/js/settings-icon.js?v=4079b66a'; import { mkGlassRim } from '/js/glass-rim.js?v=3faec233'; import { smoothRectPath } from '/js/smooth-corner.js?v=b7dda7e1'; @@ -65,6 +65,8 @@ export const isolate = text => `\u2068${text}\u2069`; export const letterTile = (l, sz, plate) => { const e = mk('span'); e.className = 'fb'; + /* A right-to-left page mirrors a lone bracket: "<" draws as ">". */ + e.dir = 'ltr'; const tone = toneForColor(plate) ?? (pageTheme() === 'light' ? 'dark' : 'light'); if (tone === 'dark') e.classList.add('fb-on-light'); e.style.fontSize = Math.round(sz * 0.32) + 'px'; @@ -81,10 +83,6 @@ export { esc } from '/js/html.js?v=c71f8903'; alignment as well as bidi, so a Latin name on the block dragged its whole row to the other edge: a title and its subtitle ended up on opposite sides. - A centred label sets `dir` on the block as well, because alignment cannot move - centred text. Without it the block's direction decides which end is cut, and - WebKit ignores unicode-bidi:plaintext for that. - @param {HTMLElement} node @param {string} text @returns {HTMLElement} */ export const setUserText = (node, text) => { node.textContent = ''; @@ -94,6 +92,17 @@ export const setUserText = (node, text) => { return node; }; +/* A centred label that ellipsises. The block's direction decides which end is + cut, so it takes dir="auto" and no : dir="auto" skips text inside a bdi + and would resolve to ltr. + + @param {HTMLElement} node @param {string} text @returns {HTMLElement} */ +export const setCentredText = (node, text) => { + node.dir = 'auto'; + node.textContent = text; + return node; +}; + /* A page strip is laid out in the page's direction, so the next page sits to the left in one direction and to the right in the other. translateX has no logical form, and a strip moved the wrong way leaves the screen: 1 where a page @@ -306,13 +315,31 @@ export function scrollsSideways(target, dx) { return false; } +/** @param {any} target @returns {boolean} */ +export function ownsSidewaysTouch(target) { + for (let n = target; n && n.nodeType === 1; n = n.parentElement) { + const view = n.ownerDocument && n.ownerDocument.defaultView; + if (!view) continue; + const st = view.getComputedStyle(n); + const ta = st.touchAction || 'auto'; + if (ta !== 'auto' && ta !== 'manipulation' && !ta.includes('pan-x')) return true; + if (n.scrollWidth > n.clientWidth && /(auto|scroll)/.test(st.overflowX)) return true; + } + return false; +} + /** @param {HTMLElement} card @param {{ src?: string, title?: string, design?: [number, number], iframeOpts?: Record, overlayHref?: string, - mobile?: boolean, onSwipe?: (dir: number) => void, + mobile?: boolean, swipe?: () => { + down: (id: string, x: number, y: number, t: number) => void, + move: (id: string, x: number, y: number, t: number) => void, + up: (id: string, t: number, fling: boolean) => void, + cancel: () => void, + } | null, }} [opts] */ -export function mountScaledWidget(card, { src, title, design, iframeOpts, overlayHref, mobile, onSwipe } = {}) { +export function mountScaledWidget(card, { src, title, design, iframeOpts, overlayHref, mobile, swipe } = {}) { const [dw, dh] = design; const o = iframeOpts || {}; /* Without a positioned ancestor the iframe escapes up the tree and paints at @@ -483,15 +510,35 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla doc.__wgesture = true; let sx = 0, sy = 0, - moved = false; + moved = false, + owned = false; + const win = /** @type {Window} */ (doc.defaultView); + /* A frame touch is in the frame's own pixels, and the frame moves with + the page strip. Map every point through the frame's current box. */ + const at = touch => { + const r = ifr.getBoundingClientRect(); + const k = ifr.offsetWidth ? r.width / ifr.offsetWidth : 1; + return [r.left + touch.clientX * k, r.top + touch.clientY * k]; + }; + const time = e => e.timeStamp + win.performance.timeOrigin - performance.timeOrigin; + const feed = () => (owned || typeof swipe !== 'function' ? null : swipe()); doc.addEventListener( 'touchstart', e => { - const touch = e.touches[0]; + const touch = e.changedTouches[0]; if (!touch) return; - sx = touch.clientX; - sy = touch.clientY; - moved = false; + if (e.touches.length === 1) { + sx = touch.clientX; + sy = touch.clientY; + moved = false; + owned = ownsSidewaysTouch(e.target); + } + if (e.touches.length > 1) { + feed()?.cancel(); + return; + } + const [x, y] = at(touch); + feed()?.down('f' + touch.identifier, x, y, time(e)); }, { passive: true }, ); @@ -499,37 +546,30 @@ export function mountScaledWidget(card, { src, title, design, iframeOpts, overla 'touchmove', e => { const touch = e.touches[0]; - if (!touch) return; - if (Math.abs(touch.clientX - sx) > 8 || Math.abs(touch.clientY - sy) > 8) moved = true; - }, - { passive: true }, - ); - doc.addEventListener( - 'touchend', - e => { - const touch = e.changedTouches[0]; - if (!touch) return; - const dx = touch.clientX - sx, - dy = touch.clientY - sy; - if (Math.abs(dx) > 30 && Math.abs(dx) > Math.abs(dy) * 1.4) { - /* horizontal swipe → page */ - if (typeof onSwipe === 'function') onSwipe(dx < 0 ? 1 : -1); - return; - } - if (!moved && overlayHref) { - /* tap on non-interactive area → open link */ - const target = e.target; - const interactive = - target && - target.closest && - target.closest( - 'a,button,[role="button"],[onclick],.clickable,.bay,.val-row,.chart-wrap,input,select,textarea', - ); - if (!interactive) window.open(overlayHref, '_blank', 'noopener,noreferrer'); + if (touch && (Math.abs(touch.clientX - sx) > 8 || Math.abs(touch.clientY - sy) > 8)) moved = true; + for (const tp of e.changedTouches) { + const [x, y] = at(tp); + feed()?.move('f' + tp.identifier, x, y, time(e)); } }, { passive: true }, ); + /** @param {boolean} fling */ + const lift = fling => e => { + for (const tp of e.changedTouches) feed()?.up('f' + tp.identifier, time(e), fling); + if (!fling || moved || !overlayHref) return; + /* tap on non-interactive area → open link */ + const target = e.target; + const interactive = + target && + target.closest && + target.closest( + 'a,button,[role="button"],[onclick],.clickable,.bay,.val-row,.chart-wrap,input,select,textarea', + ); + if (!interactive) window.open(overlayHref, '_blank', 'noopener,noreferrer'); + }; + doc.addEventListener('touchend', lift(true), { passive: true }); + doc.addEventListener('touchcancel', lift(false), { passive: true }); }; ifr.addEventListener('load', attach); cleanups.push(() => ifr.removeEventListener('load', attach)); diff --git a/ui/js/widget-config-form.js b/ui/js/widget-config-form.js index 49bd0b13..1d1e8af3 100644 --- a/ui/js/widget-config-form.js +++ b/ui/js/widget-config-form.js @@ -3,9 +3,9 @@ import { t } from '/js/i18n.js?v=ed7c5565'; import { html, raw, setHtml } from '/js/html.js?v=c71f8903'; -import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=6538c84c'; -import { createListbox } from '/js/listbox.js?v=99ce62fa'; -import { renderColorControl } from '/js/admin-color-control.js?v=aade667a'; +import { reveal, wireInlineEdit } from '/js/admin-shared.js?v=c509d11e'; +import { createListbox } from '/js/listbox.js?v=2734f4e0'; +import { renderColorControl } from '/js/admin-color-control.js?v=b3bb1707'; import { seedCarried, applyOptionSet, @@ -16,7 +16,7 @@ import { isPlainField, } from '/js/admin-logic.js?v=28471483'; import { optionsErrorAdvice, TONE } from '/js/admin-error.js?v=1c258dc1'; -import { qi } from '/js/utils.js?v=cfee1271'; +import { qi } from '/js/utils.js?v=017012d7'; import { formatNumber } from '/js/format-number.js?v=abcd4ae4'; import { SAVED_ROW } from '/js/saved-row.js?v=44039df6'; diff --git a/ui/test/contrast.test.mjs b/ui/test/contrast.test.mjs index 0f88c983..06f50c0e 100644 --- a/ui/test/contrast.test.mjs +++ b/ui/test/contrast.test.mjs @@ -480,9 +480,11 @@ const DASHBOARD_TEXT = [ ['first-run mismatch error', '.setup-err', SETUP, 'wallpaper'], ['API error title', '.api-error-screen', [], '--bg-base'], ['API error explanation', '.api-error-sub', [], '--bg-base'], - ['empty dashboard title', '.empty-state-title', [], '--bg-base'], - ['empty dashboard explanation', '.empty-state-sub', [], '--bg-base'], - ['empty dashboard import link', '.empty-state-hint', [], '--bg-base'], + /* Toned like a tile label. With no wallpaper the tone follows the theme, so + each theme's base is read with the tone it picks. */ + ['empty dashboard title', ['.empty-state-title', '.empty-state-title[data-tone="dark"]'], [], '--bg-base'], + ['empty dashboard explanation', ['.empty-state-sub', '.empty-state-sub[data-tone="dark"]'], [], '--bg-base'], + ['empty dashboard import link', ['.empty-state-hint', '.empty-state-hint[data-tone="dark"]'], [], '--bg-base'], ['Spotlight result name', '.srn', SPOTLIGHT, 'wallpaper'], ['Spotlight result address', '.srh', SPOTLIGHT, 'wallpaper'], ['Spotlight selected result address', '.srh', [...SPOTLIGHT, '.sr.sel'], 'wallpaper'], diff --git a/ui/test/dashboard-localisation.test.mjs b/ui/test/dashboard-localisation.test.mjs index 9726aea8..a3427e1a 100644 --- a/ui/test/dashboard-localisation.test.mjs +++ b/ui/test/dashboard-localisation.test.mjs @@ -221,21 +221,24 @@ test('the first-run prompt and API error are translated', () => { beginning. unicode-bidi:plaintext does nothing in WebKit, so the label carries dir - instead. That is safe only because these labels are centred: dir sets - alignment too, which moves a name off its own icon. */ + instead, through setCentredText. That is safe only because these labels are + centred: dir sets alignment too, which moves a name off its own icon. */ test('every tile label carries its own direction', () => { const ui = read('js/ui.js'); const dashboard = read('js/dashboard.js'); + let sites = 0; for (const [src, name] of [ [ui, 'ui.js'], [dashboard, 'dashboard.js'], ]) { for (const m of src.matchAll( - /className = '(ilabel|dyn-mob-label|dyn-fold-label|dyn-fold-inner-label)';\n(\s*)(.*)/g, + /className = '(ilabel|dyn-mob-label|dyn-fold-label|dyn-fold-inner-label)';\n([\s\S]*?)a\.appendChild\(l\);/g, )) { - assert.match(m[3], /^l\.dir = 'auto';$/, `${name}: .${m[1]} is cut by the page direction`); + sites++; + assert.match(m[2], /setCentredText\(l, /, `${name}: .${m[1]} is cut by the page direction`); } } + assert.equal(sites, 6, 'a tile label site moved; check it still sets its direction'); }); test('the labels the direction is set on are centred', () => { diff --git a/ui/test/dashboard-logic.test.mjs b/ui/test/dashboard-logic.test.mjs index b32f4ca0..d89de4b4 100644 --- a/ui/test/dashboard-logic.test.mjs +++ b/ui/test/dashboard-logic.test.mjs @@ -12,6 +12,12 @@ import { landingAfterSetup, restorePage, wheelPager, + fingerSpeed, + rubberBand, + swipeSettle, + settleMs, + FLICK_SPEED, + PAGE_TURN_MS, } from '../js/dashboard-logic.js'; const SPANS = { app: [1, 1], small: [1, 0], medium: [2, 0], large: [2, 2], xlarge: [2, 3] }; @@ -221,3 +227,75 @@ test('vertical scrolling and a small sideways nudge turn nothing', () => { assert.equal(step(10, 0, 32), 0); assert.equal(step(5, 0, 600), 0, 'the nudge was forgotten after the pause'); }); + +/* A finger drag on the page strip. Offsets and speeds are physical: negative is + towards the left of the screen. */ +const W = 390; +const settle = (offset, speed, { page = 1, total = 3, dir = 1 } = {}) => + swipeSettle({ page, total, offset, speed, width: W, dir }); + +test('a slow release goes to the nearest page', () => { + assert.equal(settle(-0.4 * W, 0), 1); + assert.equal(settle(-0.6 * W, 0), 2); + assert.equal(settle(0.6 * W, 0), 0); +}); + +test('a short flick turns the page by speed alone', () => { + assert.equal(settle(-35, -(FLICK_SPEED + 0.05)), 2); + assert.equal(settle(35, FLICK_SPEED + 0.05), 0); + assert.equal(settle(-35, -(FLICK_SPEED - 0.05)), 1, 'too slow to count as a flick'); +}); + +test('a flick back against the drag returns to the page it left', () => { + assert.equal(settle(-0.7 * W, 0.5), 1); +}); + +test('a flick turns one page at most', () => { + assert.equal(settle(-0.2 * W, -5), 2); +}); + +test('the first and last pages hold', () => { + assert.equal(settle(80, 2, { page: 0 }), 0); + assert.equal(settle(-80, -2, { page: 2 }), 2); +}); + +/* In a right-to-left page the next page is on the left, so a drag to the right + moves forward. */ +test('a right-to-left page mirrors the drag', () => { + assert.equal(settle(-35, -1, { dir: -1 }), 0); + assert.equal(settle(35, 1, { dir: -1 }), 2); + assert.equal(settle(0.6 * W, 0, { dir: -1 }), 2); +}); + +test('the edge gives less the further it is pulled, and never a whole page', () => { + const a = rubberBand(50, W), + b = rubberBand(100, W), + c = rubberBand(10_000, W); + assert.ok(a < 50 && b < 100); + assert.ok(b - a < a, 'the second 50px moves less than the first'); + assert.ok(c < W); +}); + +test('the finger speed comes from the last moments of the drag', () => { + const s = fingerSpeed(); + s.add(0, 0); + s.add(-100, 50); + s.add(-200, 100); + assert.equal(s.speed(100), -2); +}); + +test('a finger that stopped before lifting has no speed', () => { + const s = fingerSpeed(); + s.add(0, 0); + s.add(-100, 50); + assert.equal(s.speed(400), 0); +}); + +test('the settle starts at the finger speed and never outlasts a page turn', () => { + assert.equal(settleMs(300, 0), PAGE_TURN_MS); + assert.equal(settleMs(300, -1), PAGE_TURN_MS, 'moving away from the page'); + assert.equal(settleMs(300, 0.5), PAGE_TURN_MS); + const fast = settleMs(200, 3); + assert.ok(fast < PAGE_TURN_MS && fast >= 120, `${fast}`); + assert.equal(fast, Math.round((0.72 / 0.32) * (200 / 3))); +}); diff --git a/ui/test/empty-dashboard.test.mjs b/ui/test/empty-dashboard.test.mjs index b22183f2..1e8d5f20 100644 --- a/ui/test/empty-dashboard.test.mjs +++ b/ui/test/empty-dashboard.test.mjs @@ -63,11 +63,27 @@ test('the placard strings exist in every catalog', () => { } }); -test('the placard draws the logo from the icon set in theme colours', () => { +test('the placard draws the logo from the icon set in the label colour', () => { const src = read('js/utils.js'); assert.match(src, /href', '#sy-solid-logo'/); assert.match(src, /'sy-icon empty-state-mark'/); - assert.match(read('css/dashboard.css'), /\.empty-state-mark\s*\{[^}]*color:\s*var\(--label-primary\)/); + assert.match(read('css/dashboard.css'), /\.empty-state-mark\s*\{[^}]*color:\s*var\(--label-color\)/); +}); + +/* The placard sits on the wallpaper. A theme ink is black in a light system + theme, which cannot be read on a dark photo. */ +test('the placard text takes the tone measured on the wallpaper, not the theme', () => { + const css = read('css/dashboard.css'); + const selector = /const LABEL_SELECTOR =\s*'([^']+)'/.exec(read('js/label-contrast.js'))[1]; + assert.ok(selector.split(/,\s*/).includes('.empty-state > *'), 'the placard is not toned'); + for (const part of ['mark', 'title', 'sub', 'btn', 'hint']) { + const cls = `.empty-state-${part}`; + assert.match(css, new RegExp(`\\${cls}\\[data-tone="dark"\\]\\s*\\{[^}]*color:`), `${cls} has no dark tone`); + assert.doesNotMatch(css, new RegExp(`html\\[data-theme="light"\\][^{]*\\${cls}\\b`), `${cls} follows the theme`); + const base = new RegExp(`(?:^|\\n)\\${cls} \\{([^}]*)\\}`).exec(css); + assert.ok(base, `${cls} rule is gone`); + assert.doesNotMatch(base[1], /--label-(?:primary|secondary)/, `${cls} takes a theme ink`); + } }); test('both layouts drop the settings tile while the dashboard is empty', () => { diff --git a/ui/test/fluid-hover.test.mjs b/ui/test/fluid-hover.test.mjs index d6caf814..8e3e89aa 100644 --- a/ui/test/fluid-hover.test.mjs +++ b/ui/test/fluid-hover.test.mjs @@ -147,6 +147,28 @@ test('the pill appears on hover, is inert, and is reused', () => { assert.equal(pill.style.transform, 'translate3d(0px,50px,0)'); }); +/* A key press is a discrete step. A glide after it reads as lag. */ +test('the keyboard moves the pill at once and the pointer glides it', () => { + install(); + const list = fake('div', { rect: { left: 0, top: 0, width: 200, height: 120 } }); + list._sel = ['#sres']; + const a = fake('a', { rect: { left: 0, top: 0, width: 200, height: 40 } }); + const b = fake('a', { rect: { left: 0, top: 40, width: 200, height: 40 } }); + for (const r of [a, b]) { + r._sel = ['.sr']; + r.parent = list; + } + let over; + mod.initFluidHover({ addEventListener: (t, fn) => t === 'pointerover' && (over = fn) }); + mod.fluidHoverKb(b); + const pill = list.children[0]; + assert.ok(pill.classList.contains('mp-snap'), 'a key press must not glide'); + over({ pointerType: 'mouse', target: a }); + assert.ok(!pill.classList.contains('mp-snap'), 'the pointer glides'); + mod.fluidHoverKb(b); + assert.ok(pill.classList.contains('mp-snap')); +}); + test('right-to-left measures from the other edge and travels the other way', () => { install({ dir: 'rtl' }); const list = fake('ul', { rect: { left: 100, right: 300, top: 50, width: 200, height: 120 } }); @@ -223,6 +245,18 @@ for (const [name, text] of Object.entries(css)) { assert.match(text.slice(at), /\.mp[^}]*\{[^}]*transition\s*:\s*none/); }); + /* Size is layout. The pill moves by transform and takes its new size at once. */ + test(`${name}.css: the pill glides by transform only, and a snap drops the glide`, () => { + const on = /\.mp\.on\s*\{([^}]*)\}/.exec(text); + assert.ok(on, 'the pill has no on rule'); + assert.match(on[1], /transform\s+[\d.]+s/); + assert.doesNotMatch(on[1], /\b(width|height)\s+[\d.]+s/); + const snap = /\.mp\.mp-snap\s*\{([^}]*)\}/.exec(text); + assert.ok(text.indexOf(snap?.[0]) > text.indexOf(on[0]), 'the snap rule must come after the on rule'); + assert.ok(snap, 'the snap rule is gone'); + assert.doesNotMatch(snap[1], /transform/); + }); + test(`${name}.css: the row fill gives way only while the pill is on screen`, () => { assert.match(text, /:has\(> \.fh-hl\.on\)/); }); diff --git a/ui/test/folder-overlay.test.mjs b/ui/test/folder-overlay.test.mjs index d2ba6144..a5619a52 100644 --- a/ui/test/folder-overlay.test.mjs +++ b/ui/test/folder-overlay.test.mjs @@ -111,3 +111,53 @@ test('the desktop overlay takes focus itself too', () => { assert.match(desktop, /ov\.tabIndex = -1/); assert.match(desktop, /ov\.showModal\(\);\s*ov\.focus\(\)/); }); + +/* A non-passive touchmove listener holds every scroll on the main thread. */ +test('the folder box stops panning by touch-action, not by a blocking listener', () => { + assert.match(css, /\.dyn-box-mob\s+\{[^}]*touch-action:none/); + assert.doesNotMatch( + overlay, + /passive: false \}\);\s*box\.addEventListener|box\.addEventListener\(\s*'touchmove',[\s\S]{0,200}preventDefault/, + ); + assert.match(overlay, /swipePager\(box, strip, \{/, 'the folder pages swipe'); +}); + +const motion = ui.slice(ui.indexOf('function folderMotion('), ui.indexOf('let folderOverlay = null;')); + +test('a folder grows from the size and place of the tile that opened it', () => { + assert.match(motion, /const s = from\.width \/ r\.width;/); + assert.match(motion, /return `translate\(\$\{dx\}px, \$\{dy\}px\) scale\(\$\{s\}\)`;/); + assert.match(motion, /p\.style\.transformOrigin = '0 0'/); + assert.match(motion, /duration: opening \? 300 : 250/); + assert.match(ui, /openFolderDesktop\(item, a\)/); + assert.match(ui, /openFolderMobile\(item, isz, ir, im, sc, a\)/); +}); + +test('reduced motion keeps only the fade', () => { + assert.match(motion, /prefers-reduced-motion: reduce/); + assert.match(motion, /!still && tile\?\.isConnected/); + assert.match(motion, /if \(!from\?\.width\) return 'none';/); +}); + +/* The dialog closes at once, so focus goes back to the tile and the dashboard + stops being inert. Only the picture shrinks away. */ +test('a closing folder takes no input and shrinks from where it is', () => { + assert.match(motion, /getComputedStyle\(s\.el\)\[s\.prop\]/, 'an interrupted open would jump'); + assert.match( + css, + /:is\(\.folder-overlay, \.folder-overlay-mobile\):not\(\[open\]\) \* \{ pointer-events:none !important \}/, + ); + assert.match(css, /\.folder-overlay-mobile:not\(\[open\]\) \{ display:block \}/); + assert.match( + css, + /:is\(\.folder-overlay, \.folder-overlay-mobile\):not\(\[open\]\) \{\s*background:none !important; -webkit-backdrop-filter:none !important; backdrop-filter:none !important;/, + 'a fading blur darkens the top edge of the screen', + ); + assert.match(motion, /running = steps\s*\.slice\(1\)/, 'only the panel animates the close'); + const dash = read('js/dashboard.js'); + assert.doesNotMatch( + dash, + /querySelector\('\.folder-overlay'\)|'\.folder-overlay, \.folder-overlay-mobile'/, + 'a closing folder would block the pager', + ); +}); diff --git a/ui/test/live-region.test.mjs b/ui/test/live-region.test.mjs index 565e1368..3d287e23 100644 --- a/ui/test/live-region.test.mjs +++ b/ui/test/live-region.test.mjs @@ -60,10 +60,15 @@ test('the announcement is made where the page changes', () => { /* Every route to another page, arrow keys, swipes, and the dots, goes through goTo, so one announcement covers all of them. */ test('every way of changing page goes through goTo', () => { - for (const route of [/ArrowRight'\) goTo/, /ArrowLeft'\)\s+goTo/, /touchend[\s\S]{0,200}goTo\(pg/]) { + for (const route of [ + /ArrowRight'\) goTo/, + /ArrowLeft'\)\s+goTo/, + /swipePager\(document,[\s\S]{0,300}turn: n => goTo\(n\)/, + ]) { assert.match(dashboard, route, 'a page change that bypasses goTo would be silent'); } - assert.match(read('js/ui.js'), /onSwipe: dir => goTo\(/); + assert.match(read('js/ui.js'), /swipe: \(\) => CB\(\)\.homeSwipe/); + assert.match(dashboard, /CB\.homeSwipe = swipePager\(document,/); }); /* Repeating the same page would otherwise announce it again on every click. */ diff --git a/ui/test/motion-preferences.test.mjs b/ui/test/motion-preferences.test.mjs index 021b50fd..c9cfef8d 100644 --- a/ui/test/motion-preferences.test.mjs +++ b/ui/test/motion-preferences.test.mjs @@ -48,13 +48,57 @@ test('reduced transparency is handled in the shared stylesheet', () => { assert.match(tokens, /@media \(prefers-reduced-transparency: reduce\)/); }); -/* A duration of exactly 0 skips the transitionend event some code waits on, so - the value has to be small rather than zero. */ -test('reduced motion neutralises rather than removes transitions', () => { +/* Reduced motion asks for less movement, not for no change. A fade is not + movement, so it keeps its timing; a slide, scale or resize jumps. */ +test('reduced motion keeps fades and drops movement', () => { const block = tokens.slice(tokens.indexOf('@media (prefers-reduced-motion')); - assert.match(block, /transition-duration: \.01ms !important/); - assert.match(block, /animation-duration: \.01ms !important/); - assert.doesNotMatch(block.slice(0, block.indexOf('}')), /duration: 0s/); + const rule = block.slice(0, block.indexOf('}')); + const props = /transition-property: ([^;!]+) !important/.exec(rule); + assert.ok(props, 'the transition allowlist is gone'); + const list = props[1].split(',').map(p => p.trim()); + for (const keep of ['opacity', 'color', 'background-color']) + assert.ok(list.includes(keep), `${keep} no longer fades`); + for (const drop of [ + 'all', + 'transform', + 'translate', + 'scale', + 'rotate', + 'width', + 'height', + 'inset', + 'top', + 'left', + 'clip-path', + 'filter', + ]) { + assert.ok(!list.includes(drop), `${drop} still animates under reduced motion`); + } + assert.doesNotMatch(rule, /transition-duration/, 'a duration override cuts the fades as well'); + /* Not zero. A duration of exactly 0 skips the animationend event. */ + assert.match(rule, /animation-duration: \.01ms !important/); + assert.doesNotMatch(rule, /duration: 0s/); +}); + +/* With the property dropped, transitionend never fires for it. Every listener + needs a fallback or must not depend on the event. */ +test('code that waits on transitionend has a way out under reduced motion', () => { + const dir = path.join(root, 'js'); + const waiters = fs + .readdirSync(dir) + .filter(f => f.endsWith('.js')) + .filter(f => /'transitionend'/.test(read(`js/${f}`))); + assert.deepEqual( + waiters.sort(), + ['admin-drag.js', 'admin-shared.js', 'dashboard.js'], + 'a new transitionend listener needs a fallback', + ); + assert.match(read('js/admin-shared.js'), /timer = setTimeout\(end, \d+\)/); + assert.match(read('js/admin-drag.js'), /timer = setTimeout\(done, \d+\)/); + assert.match( + dashboard.slice(dashboard.indexOf('@media (prefers-reduced-motion')), + /#pages \{ transition: none !important; \}/, + ); }); test('every page ends up honouring all three preferences', () => { diff --git a/ui/test/pagination.test.mjs b/ui/test/pagination.test.mjs index 6216a69f..a0dd195a 100644 --- a/ui/test/pagination.test.mjs +++ b/ui/test/pagination.test.mjs @@ -123,7 +123,7 @@ const keydown = dashboard.slice( ); const mouseup = dashboard.slice( dashboard.indexOf("document.addEventListener('mouseup'"), - dashboard.indexOf('let _dTx'), + dashboard.indexOf("'touchstart'", dashboard.indexOf("document.addEventListener('mouseup'")), ); test('arrow keys page on the phone layout too', () => { @@ -155,10 +155,62 @@ test('an open folder overlay stops the keyboard pager', () => { assert.match(keydown, /folder-overlay/); }); -test('the touch pager still belongs to one layout only', () => { - const at = dashboard.indexOf("'touchend'"); - const touchend = dashboard.slice(at, dashboard.indexOf('{ passive: true }', at)); - assert.match(touchend, /if \(MOB\) return/, 'both swipe handlers would advance two pages for one gesture'); +/* Two touch pagers would advance two pages for one gesture. */ +test('one touch pager serves both layouts', () => { + assert.equal(dashboard.match(/swipePager\(document,/g)?.length, 1); + const ui = read('js/ui.js'); + const build = ui.slice(ui.indexOf('export function buildMobile('), ui.indexOf('export function resetMobileChrome(')); + assert.doesNotMatch(build, /swipePager\(document|goTo\(st\(\)\.pg \+ \(dx/, 'the phone build pages on its own'); +}); + +/* The swipe follows the finger and settles from where the finger left it. */ +const ui = read('js/ui.js'); +const pager = ui.slice(ui.indexOf('export function swipePager('), ui.indexOf('function folderMotion(')); + +test('the strip follows the finger while it is down', () => { + assert.match(pager, /move\(pid, px, py, ts\) \{[\s\S]*strip\.style\.transform = [^;]*translateX\(\$\{x\}px\)/); + assert.match(pager, /strip\.style\.transition = 'none'/); +}); + +test('the release settles from the finger, at the finger speed', () => { + assert.match(pager, /swipeSettle\(\{/); + assert.match(pager, /settleMs\(/); + assert.match(pager, /strip\.style\.transition = `transform \$\{ms\}ms var\(--page-ease\)`;\s*o\.turn\(n\)/); +}); + +test('only the first finger drags, and a vertical drag is left alone', () => { + assert.match(pager, /if \(id !== null \|\| /); + assert.match(pager, /p\.identifier === id/); + assert.match(pager, /axis = Math\.abs\(dx\) > Math\.abs\(dy\) \? 'x' : 'y'/); +}); + +test('a finger whose touchend never arrived does not lock the pager', () => { + assert.match( + pager, + /'touchstart',[\s\S]{0,200}if \(id !== null && e\.touches\.length === e\.changedTouches\.length\) feed\.up\(id, e\.timeStamp, false\);/, + ); +}); + +test('the page strip has no touch listener that blocks scrolling', () => { + assert.doesNotMatch(pager, /passive: false/); + assert.doesNotMatch(pager, /preventDefault/); +}); + +test('an open folder or search keeps the home pager still', () => { + const call = dashboard.slice(dashboard.indexOf('swipePager(document,')); + assert.match( + call.slice(0, 400), + /enabled: \(\) =>[\s\S]*spot[\s\S]*folder-overlay\[open\], \.folder-overlay-mobile\[open\]/, + ); +}); + +/* An ease-in-out barely moves for the first 100ms, and a keyboard page turn + reads as stuck. */ +test('a page turns in 280ms on an ease-out curve', () => { + const css = read('css/dashboard.css'); + assert.match(css, /--page-ease:\s+cubic-bezier\(\.32,\.72,0,1\);/); + assert.match(css, /#pages \{[^}]*transition:transform \.28s var\(--page-ease\);/); + assert.match(css, /\.dyn-strip\s+\{[^}]*transition:transform \.28s var\(--page-ease\);/); }); /* A strip is laid out in the page's direction, so the next page is on the left @@ -214,3 +266,21 @@ test('the hidden pager is not painted', () => { 'the bar must start hidden, or the error screen shows it empty', ); }); + +/* A widget frame swallows touches, so it hands them to the same pager. Its + points are in the frame's own pixels, and the frame moves with the strip. */ +test('a swipe on a phone widget drives the home pager', () => { + const utils = read('js/utils.js'); + const gesture = utils.slice( + utils.indexOf('An iframe swallows touches'), + utils.indexOf("ifr.addEventListener('load', attach)"), + ); + assert.match(gesture, /const r = ifr\.getBoundingClientRect\(\);/, 'the point must be mapped at every event'); + assert.match(gesture, /r\.left \+ touch\.clientX \* k/); + assert.match(gesture, /owned = ownsSidewaysTouch\(e\.target\)/, 'a widget scrubber would page'); + assert.match(gesture, /feed\(\)\?\.down\(/); + assert.match(gesture, /feed\(\)\?\.move\(/); + assert.match(gesture, /feed\(\)\?\.up\(/); + assert.match(gesture, /if \(!fling \|\| moved \|\| !overlayHref\) return;/, 'a tap must still open the link'); + assert.doesNotMatch(gesture, /onSwipe/); +}); diff --git a/ui/test/rtl-logical-properties.test.mjs b/ui/test/rtl-logical-properties.test.mjs index cb3fdbcb..5936dbd5 100644 --- a/ui/test/rtl-logical-properties.test.mjs +++ b/ui/test/rtl-logical-properties.test.mjs @@ -455,6 +455,8 @@ test("the phone folder title starts on the panel's leading edge", () => { test('every swipe and drag pager mirrors its direction', () => { const ui = code('js/ui.js'); const calls = [...ui.matchAll(/(goTo|gotoPage)\((st\(\)\.pg|curPage) \+ ([^)]*\)?[^)]*)\)/g)].map(m => m[0]); - assert.ok(calls.length >= 3, 'the pager calls moved'); for (const c of calls) assert.match(c, /pageDir\(\)/, `${c} ignores the page direction`); + const pager = ui.slice(ui.indexOf('export function swipePager('), ui.indexOf('function folderMotion(')); + assert.match(pager, /const rest = n => -pageDir\(\) \* n \* o\.width\(\);/, 'a page rests on the wrong side'); + assert.match(pager, /dir: pageDir\(\)/, 'a release settles the wrong way'); }); diff --git a/ui/test/scrolls-sideways.test.mjs b/ui/test/scrolls-sideways.test.mjs index 4580328a..e2305985 100644 --- a/ui/test/scrolls-sideways.test.mjs +++ b/ui/test/scrolls-sideways.test.mjs @@ -3,9 +3,9 @@ import assert from 'node:assert/strict'; import { register } from 'node:module'; register('./js-root-hooks.mjs', import.meta.url); -const { scrollsSideways } = await import('../js/utils.js'); +const { scrollsSideways, ownsSidewaysTouch } = await import('../js/utils.js'); -const view = { getComputedStyle: n => ({ overflowX: n.ox }) }; +const view = { getComputedStyle: n => ({ overflowX: n.ox, touchAction: n.ta ?? 'auto' }) }; const node = (props, parent = null) => ({ nodeType: 1, ownerDocument: { defaultView: view }, @@ -38,3 +38,19 @@ test('a strip already at its end hands the wheel to the pager', () => { test('overflow that is clipped, not scrollable, does not count', () => { assert.equal(scrollsSideways(node({ ox: 'hidden', scrollWidth: 400, clientWidth: 100 }), 30), false); }); + +/* A widget swipe drags the home pages, unless the widget takes the sideways + touch for itself. */ +test('a plain widget leaves a sideways touch to the home pager', () => { + assert.equal(ownsSidewaysTouch(node({}, node({ ta: 'manipulation' }))), false); + assert.equal(ownsSidewaysTouch(node({ ta: 'pan-x pan-y' })), false); +}); + +test('a scrubber that takes sideways touch keeps it', () => { + assert.equal(ownsSidewaysTouch(node({}, node({ ta: 'pan-y' }))), true); + assert.equal(ownsSidewaysTouch(node({ ta: 'none' })), true); +}); + +test('a strip that scrolls sideways keeps the touch', () => { + assert.equal(ownsSidewaysTouch(node({}, node({ ox: 'auto', scrollWidth: 400, clientWidth: 100 }))), true); +}); diff --git a/ui/test/spotlight-dismiss.test.mjs b/ui/test/spotlight-dismiss.test.mjs index a0c6e60e..1d58c151 100644 --- a/ui/test/spotlight-dismiss.test.mjs +++ b/ui/test/spotlight-dismiss.test.mjs @@ -3,6 +3,9 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { register } from 'node:module'; + +register('./js-root-hooks.mjs', import.meta.url); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const read = p => fs.readFileSync(path.join(root, p), 'utf8'); @@ -28,6 +31,87 @@ test('closing search puts focus back where it was', () => { assert.match(js, /returnFocus = document\.activeElement;\s*ov\.classList\.add\('on'\);\s*ov\.showModal\(\);/); }); +class FakeEl extends EventTarget { + constructor(id) { + super(); + this.id = id; + this.value = ''; + this.style = {}; + this.attrs = {}; + this.children = []; + const cls = new Set(); + this.classList = { add: c => cls.add(c), remove: c => cls.delete(c), toggle() {} }; + } + setAttribute(n, v) { + this.attrs[n] = String(v); + } + appendChild(c) { + this.children.push(c); + } + replaceChildren() { + this.children = []; + } + querySelector() { + return null; + } + focus() { + globalThis.document.activeElement = this; + } + blur() {} + get isConnected() { + return true; + } +} + +/* Chromium puts focus back on close() and fires the close event later, in its + own task. */ +function spotlightHarness() { + const ov = new FakeEl('spot'); + const inp = new FakeEl('sin'); + let before = null; + ov.open = false; + ov.showModal = () => { + ov.open = true; + before = globalThis.document.activeElement; + }; + ov.close = () => { + ov.open = false; + before?.focus(); + }; + ov.contains = n => n === ov || n === inp; + const byId = { spot: ov, sin: inp, sres: new FakeEl('sres'), 'spot-cancel': new FakeEl('spot-cancel') }; + const pill = new FakeEl('pill'); + globalThis.HTMLElement = FakeEl; + globalThis.window = globalThis; + globalThis.requestAnimationFrame = () => 0; + globalThis.document = { + activeElement: pill, + body: new FakeEl('body'), + getElementById: id => byId[id] ?? null, + createElement: tag => new FakeEl(tag), + addEventListener() {}, + }; + return { ov, inp, pill }; +} + +test('a close event that lands after Search reopened leaves focus in the field', async () => { + const { ov, inp, pill } = spotlightHarness(); + const { initSpotlight } = await import('../js/spotlight.js'); + const CB = {}; + initSpotlight({ getItems: () => [], isMob: () => false, typeToOpen: () => true, CB, iconChain: () => [] }); + CB.spotOpen(''); + CB.spotClose(); + CB.spotOpen('a'); + assert.equal(document.activeElement, inp); + ov.dispatchEvent(new Event('close')); + assert.equal(document.activeElement, inp, 'the late close moved focus out of the reopened field'); + + CB.spotClose(); + inp.focus(); + ov.dispatchEvent(new Event('close')); + assert.equal(document.activeElement, pill, 'a plain close no longer puts focus back'); +}); + test('a folder result shows the folder icon grid', () => { assert.match(js, /if \(isFolder && folderGlyph\) \{[\s\S]*?folderGlyph\(app, 44\)/); }); diff --git a/ui/test/swipe-gestures.test.mjs b/ui/test/swipe-gestures.test.mjs new file mode 100644 index 00000000..ac3251f3 --- /dev/null +++ b/ui/test/swipe-gestures.test.mjs @@ -0,0 +1,202 @@ +/* The home pager follows one finger on the document and on widget frames. A + gesture the pager never sees end strands the strip between two pages. A + pinch is not a swipe and must not turn the page. */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { register } from 'node:module'; + +register('./js-root-hooks.mjs', import.meta.url); + +globalThis.window = globalThis; +globalThis.addEventListener = () => {}; +globalThis.document = { documentElement: {}, addEventListener() {}, removeEventListener() {} }; +globalThis.getComputedStyle = () => ({ transform: 'none', direction: 'ltr' }); +globalThis.requestAnimationFrame = () => 0; + +const { swipePager, onTap } = await import('../js/ui.js'); + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const WIDTH = 400; + +function mkStrip() { + const cls = new Set(); + return { + style: {}, + classList: { add: c => cls.add(c), remove: c => cls.delete(c), contains: c => cls.has(c) }, + }; +} + +function pager(target, page = 0) { + const strip = mkStrip(); + const turned = []; + const feed = swipePager(target, strip, { + width: () => WIDTH, + page: () => page, + total: () => 3, + turn: n => turned.push(n), + }); + return { strip, turned, feed }; +} + +const pt = (id, x, y = 500) => ({ identifier: id, clientX: x, clientY: y }); + +function touchEvent(type, changed, all, t) { + const e = new Event(type, { cancelable: true }); + Object.defineProperties(e, { + changedTouches: { value: changed }, + touches: { value: all }, + timeStamp: { value: t }, + }); + return e; +} + +/* Bubbles from the first target to the last, as from the pill to the document. */ +function send(targets, type, changed, all, t) { + const events = []; + for (const target of targets) { + const e = touchEvent(type, changed, all, t); + target.dispatchEvent(e); + events.push(e); + if (e.cancelBubble) break; + } + return events; +} + +/* A slow drag, so the release is decided by distance and not by a flick. */ +function drag(targets, id, from, to, t0 = 0) { + send(targets, 'touchstart', [pt(id, from)], [pt(id, from)], t0); + const steps = 6; + for (let i = 1; i <= steps; i++) { + const x = from + ((to - from) * i) / steps; + send(targets, 'touchmove', [pt(id, x)], [pt(id, x)], t0 + i * 200); + } + return send(targets, 'touchend', [pt(id, to)], [], t0 + steps * 200 + 500); +} + +test('a swipe that starts on the Search pill turns the page and does not open Search', () => { + const pill = new EventTarget(); + const doc = new EventTarget(); + let opened = 0; + onTap(pill, () => opened++); + const { strip, turned } = pager(doc); + const lift = drag([pill, doc], 0, 300, 300 - WIDTH * 0.7); + assert.equal(opened, 0, 'a swipe opened Search'); + assert.equal(lift.length, 2, 'the lift never reached the pager'); + assert.equal(lift[0].defaultPrevented, false); + assert.deepEqual(turned, [1]); + assert.equal(strip.classList.contains('dragging'), false, 'the strip is left mid-drag'); +}); + +test('a short swipe on the pill springs back and does not open Search', () => { + const pill = new EventTarget(); + const doc = new EventTarget(); + let opened = 0; + onTap(pill, () => opened++); + const { strip, turned } = pager(doc); + drag([pill, doc], 0, 300, 270); + assert.equal(opened, 0); + assert.deepEqual(turned, [0], 'the strip is not settled back'); + assert.equal(strip.classList.contains('dragging'), false); +}); + +test('a tap on the pill opens Search and is kept from the pager', () => { + const pill = new EventTarget(); + const doc = new EventTarget(); + let opened = 0; + onTap(pill, () => opened++); + const { turned } = pager(doc); + send([pill, doc], 'touchstart', [pt(0, 200)], [pt(0, 200)], 0); + send([pill, doc], 'touchmove', [pt(0, 204)], [pt(0, 204)], 40); + const lift = send([pill, doc], 'touchend', [pt(0, 204)], [], 80); + assert.equal(opened, 1); + assert.equal(lift.length, 1); + assert.equal(lift[0].defaultPrevented, true, 'the browser would also send a click and open Search twice'); + assert.deepEqual(turned, []); +}); + +test('a click opens Search, except the one a swipe leaves behind', () => { + const pill = new EventTarget(); + let opened = 0; + onTap(pill, () => opened++); + pill.dispatchEvent(new Event('click')); + assert.equal(opened, 1); + drag([pill], 0, 300, 200); + pill.dispatchEvent(new Event('click')); + assert.equal(opened, 1, 'the click after a swipe opened Search'); + pill.dispatchEvent(new Event('click')); + assert.equal(opened, 2); +}); + +test('the phone Search pill opens through the tap handler', () => { + const ui = fs.readFileSync(path.join(root, 'js/ui.js'), 'utf8'); + const build = ui.slice(ui.indexOf('export function buildMobile('), ui.indexOf('export function resetMobileChrome(')); + assert.match(build, /onTap\(pillNew, /); + assert.doesNotMatch(build, /pillNew\.addEventListener\(\s*'touchend'/); + assert.doesNotMatch(build, /pillNew\.onclick/); +}); + +test('a second finger on the page springs the strip back', () => { + const doc = new EventTarget(); + const { strip, turned } = pager(doc, 1); + send([doc], 'touchstart', [pt(0, 300)], [pt(0, 300)], 0); + send([doc], 'touchmove', [pt(0, 250)], [pt(0, 250)], 200); + send([doc], 'touchstart', [pt(1, 100)], [pt(0, 250), pt(1, 100)], 300); + assert.deepEqual(turned, [1], 'the strip is not sent back to the current page'); + assert.equal(strip.classList.contains('dragging'), false); + const at = strip.style.transform; + send([doc], 'touchmove', [pt(0, 0), pt(1, 380)], [pt(0, 0), pt(1, 380)], 400); + send([doc], 'touchend', [pt(0, 0)], [pt(1, 380)], 900); + send([doc], 'touchend', [pt(1, 380)], [], 950); + assert.deepEqual(turned, [1], 'the pinch turned the page'); + assert.equal(strip.style.transform, at, 'the strip still follows a finger'); +}); + +test('a second finger in a widget frame springs the strip back', async () => { + const { mountScaledWidget } = await import('../js/utils.js'); + const frameDoc = new EventTarget(); + Object.assign(frameDoc, { + readyState: 'complete', + documentElement: { setAttribute() {}, style: {} }, + defaultView: { performance: { timeOrigin: performance.timeOrigin } }, + }); + const el = tag => ({ + tagName: tag, + style: { cssText: '', setProperty() {} }, + setAttribute() {}, + getAttribute: () => null, + appendChild() {}, + addEventListener() {}, + removeEventListener() {}, + offsetWidth: 200, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 200, height: 100 }), + get contentDocument() { + return tag === 'iframe' ? frameDoc : null; + }, + }); + const doc = globalThis.document; + globalThis.document = { ...doc, documentElement: { getAttribute: () => null }, createElement: el }; + try { + const { strip, turned, feed } = pager(null, 1); + mountScaledWidget(el('div'), { + src: '/widgets/clock/index.html', + title: 'w', + design: [200, 100], + mobile: true, + swipe: () => feed, + }); + send([frameDoc], 'touchstart', [pt(0, 150)], [pt(0, 150)], 0); + send([frameDoc], 'touchmove', [pt(0, 100)], [pt(0, 100)], 200); + assert.equal(strip.classList.contains('dragging'), true, 'the frame does not feed the pager'); + send([frameDoc], 'touchstart', [pt(1, 20)], [pt(0, 100), pt(1, 20)], 300); + assert.deepEqual(turned, [1], 'the strip is not sent back to the current page'); + send([frameDoc], 'touchmove', [pt(0, -150)], [pt(0, -150), pt(1, 20)], 400); + send([frameDoc], 'touchend', [pt(0, -150)], [pt(1, 20)], 900); + assert.deepEqual(turned, [1], 'the pinch turned the page'); + } finally { + globalThis.document = doc; + } +}); diff --git a/ui/test/tile-press.test.mjs b/ui/test/tile-press.test.mjs new file mode 100644 index 00000000..ea612e9a --- /dev/null +++ b/ui/test/tile-press.test.mjs @@ -0,0 +1,118 @@ +/* Every tile presses the same way on touch-down: the desktop app tile, the + desktop folder, and the phone app, Dock and folder tiles. Source-shape tests: + :active needs a real pointer. */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const read = p => fs.readFileSync(path.join(root, p), 'utf8'); +const css = read('css/dashboard.css'); +const dashboard = read('js/dashboard.js'); + +const TILES = [ + '.icon:active .iwrap', + '.icon:active .dyn-folder-box', + '.dyn-mob-icon:active > .plate', + '.dyn-dock-icon:active > .plate', + '.dyn-mob-btn:active > .dyn-box', + '.dyn-fold-anchor:active > .plate', +]; + +test('every tile shares the desktop press', () => { + const rule = css.match(/\.icon:active \.iwrap,[^{]*\{[^}]*\}/)?.[0] ?? ''; + for (const sel of TILES) assert.ok(rule.includes(sel), `${sel} does not press`); + assert.match(rule, /transform:scale\(\.92\); filter:brightness\(\.88\);/); + assert.match(rule, /transition:transform \.1s ease-out, filter \.1s ease-out;/); +}); + +test('the desktop folder lifts on hover like an app tile', () => { + const rule = css.match(/@media \(hover:hover\) \{\s*\.icon:hover \.iwrap,[^}]*\}/)?.[0] ?? ''; + assert.ok(rule.includes('.icon:hover .dyn-folder-box')); + assert.match(rule, /transform:scale\(1\.03\)/); +}); + +/* Keep. Without it Safari leaves the tile scaled after a new tab opens. */ +test('a phone tile that opens a new tab resets its press', () => { + for (const cls of ['dyn-mob-icon', 'dyn-dock-icon', 'dyn-fold-anchor']) { + assert.match(css, new RegExp(`\\.${cls}\\.settled > \\.plate`), `${cls} has no .settled reset`); + assert.match(dashboard, new RegExp(`a\\[target="_blank"\\]:is\\([^)]*\\.${cls}`), `${cls} is never settled`); + } +}); + +/* [ids, classes, types]. Pseudo-classes count as classes; :is() counts as its + most specific argument. */ +function specificity(sel) { + const total = [0, 0, 0]; + let rest = sel.replace(/:(?:is|not|has)\(([^()]*)\)/g, (_, list) => { + const best = list + .split(',') + .map(specificity) + .sort((a, b) => b[0] - a[0] || b[1] - a[1] || b[2] - a[2])[0]; + for (let i = 0; i < 3; i++) total[i] += best[i]; + return ' '; + }); + rest = rest.replace(/:where\([^()]*\)/g, ' '); + total[0] += (rest.match(/#[\w-]+/g) || []).length; + total[1] += (rest.match(/\.[\w-]+|\[[^\]]*\]|:(?!:)[\w-]+/g) || []).length; + total[2] += (rest.match(/(?:^|[\s>+~])[a-z][\w-]*/gi) || []).length; + return total; +} +const beats = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; + +test('the specificity helper counts what the cascade counts', () => { + assert.deepEqual(specificity('.icon:active .iwrap'), [0, 3, 0]); + assert.deepEqual(specificity('.dyn-mob-icon:hover > .plate'), [0, 3, 0]); + assert.deepEqual(specificity(':is(#pages, .dyn-strip).dragging :is(.plate, .dyn-box)'), [1, 2, 0]); + assert.deepEqual(specificity('body.is-mob a'), [0, 1, 2]); +}); + +const TILE_SURFACE = /\.(plate|iwrap|dwrap|dyn-box|dyn-folder-box)\s*$/; + +/* Each selector that scales or dims a tile on press or hover. */ +function pressAndHover() { + const found = []; + for (const m of css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + if (!/(?:^|[;\s])(?:transform|filter)\s*:\s*(?!none)/.test(m[2])) continue; + for (const sel of m[1].split(',').map(x => x.trim())) { + if (/:(?:active|hover)/.test(sel) && TILE_SURFACE.test(sel)) found.push(sel); + } + } + return found; +} + +/* A drag that starts on a tile keeps :active, so the drag rule must outrank + every press and hover. */ +test('a drag that starts on a tile is not a press', () => { + const drag = css.match(/([^{}]*\.dragging[^{}]*)\{\s*transform:none; filter:none\s*\}/)?.[1].trim(); + assert.ok(drag, 'the drag rule is gone'); + const rivals = pressAndHover(); + assert.ok(rivals.length >= 15, `expected every press and hover, found ${rivals.length}`); + for (const sel of rivals) { + assert.ok( + beats(specificity(drag), specificity(sel)) > 0, + `${sel} (${specificity(sel)}) outranks ${drag} (${specificity(drag)})`, + ); + } +}); + +test('a long press on a phone tile shows no link preview', () => { + for (const cls of ['dyn-mob-icon', 'dyn-dock-icon']) { + assert.match(css, new RegExp(`\\.${cls}\\s+\\{[^}]*-webkit-touch-callout:none`), cls); + } +}); + +test('reduced motion drops the scale on every tile', () => { + const block = css.slice(css.indexOf('/* Reduced motion: neutralize')); + for (const sel of [ + '.icon .dyn-folder-box', + '.dyn-mob-icon > .plate', + '.dyn-dock-icon > .plate', + '.dyn-mob-btn > .dyn-box', + ]) { + assert.ok(block.slice(0, 800).includes(sel), sel); + } +}); diff --git a/ui/test/user-text-direction.test.mjs b/ui/test/user-text-direction.test.mjs index 0fc86681..6a560bb0 100644 --- a/ui/test/user-text-direction.test.mjs +++ b/ui/test/user-text-direction.test.mjs @@ -83,15 +83,39 @@ test('no translated sentence takes a user-supplied name without isolating it', ( ); }); -test('setUserText is what the dashboard, folders, search and admin all use', () => { +test('setUserText or setCentredText is what the dashboard, folders, search and admin all use', () => { /* Named individually so removing the call from one of them fails here rather than only showing up as a truncated name in a right-to-left language. */ for (const file of ['ui.js', 'dashboard.js', 'spotlight.js', 'admin.js', 'admin-settings.js']) { const src = fs.readFileSync(path.join(JS_DIR, file), 'utf8'); - assert.match(src, /setUserText/, `${file} should render user-supplied names through setUserText`); + assert.match(src, /set(?:User|Centred)Text\(/, `${file} should render user-supplied names through setUserText`); } }); +/* dir="auto" skips text inside a . A block holding one finds no strong + character, resolves to ltr, and a Persian label then loses its start: Blink + keeps the tail, WebKit cuts both ends with no ellipsis. */ +test('a centred label carries its direction on the block, with the text bare', async () => { + const { setCentredText } = await import('../js/utils.js'); + const node = /** @type {any} */ ({ dir: '', textContent: 'old', appendChild: () => assert.fail('no child') }); + const returned = setCentredText(node, 'مدیریت سرورهای خانگی'); + assert.equal(node.dir, 'auto'); + assert.equal(node.textContent, 'مدیریت سرورهای خانگی'); + assert.equal(returned, node); +}); + +test('no block marked dir="auto" puts its text in a bdi', () => { + const offenders = []; + for (const file of fs.readdirSync(JS_DIR).filter(f => f.endsWith('.js'))) { + const src = fs.readFileSync(path.join(JS_DIR, file), 'utf8'); + for (const m of src.matchAll(/\b(\w+)\.dir = 'auto';/g)) { + const after = src.slice(m.index, m.index + 400); + if (new RegExp(`setUserText\\(${m[1]},`).test(after)) offenders.push(`${file}: ${m[1]}`); + } + } + assert.deepEqual(offenders, [], 'use setCentredText(node, name) for a centred label'); +}); + /* The isolation belongs on the text, not on the block that holds it. `dir` sets alignment as well as bidi, so marking the block drags the row's alignment to the other edge. */ @@ -127,6 +151,51 @@ test('setUserText isolates the name in a bdi and leaves the block alone', async assert.equal(returned, node, 'returns the node so it can be appended inline'); }); +/* A right-to-left page mirrors a lone bracket, so "". */ +test('an initial on a letter plate is laid out left to right', () => { + const utils = fs.readFileSync(path.join(JS_DIR, 'utils.js'), 'utf8'); + const plate = utils.slice(utils.indexOf('export const letterTile')); + assert.match(plate.slice(0, plate.indexOf('\n};')), /e\.dir = 'ltr';/); + const spot = fs.readFileSync(path.join(JS_DIR, 'spotlight.js'), 'utf8'); + assert.match(spot, /ic\.className = 'sri';\s*ic\.dir = 'ltr';/); +}); + +test('an initial in a folder thumbnail is laid out left to right', async () => { + const doc = globalThis.document; + const win = globalThis.window; + const node = tag => ({ + tag, + dir: '', + className: '', + textContent: '', + children: [], + style: { setProperty() {} }, + getContext: () => null, + appendChild(c) { + this.children.push(c); + }, + }); + globalThis.document = { createElement: node, addEventListener() {} }; + globalThis.window = { addEventListener() {}, location: {} }; + try { + const { initUI, mkFolderGlyph } = await import('../js/ui.js'); + initUI({ items: [{ id: 'a', type: 'app', label: '"'); + } finally { + globalThis.document = doc; + globalThis.window = win; + } + const ui = fs.readFileSync(path.join(JS_DIR, 'ui.js'), 'utf8'); + assert.equal( + ui.match(/'folder-mini-fb/g)?.length, + 2, + 'the fallback span is built in a second place this test does not cover', + ); +}); + /* Indexing takes one UTF-16 unit, half of an emoji. */ test('no initial is taken by indexing a name', () => { const files = fs.readdirSync(JS_DIR).filter(f => f.endsWith('.js')); diff --git a/ui/widgets/upnext/index.html b/ui/widgets/upnext/index.html index 9fa4be9b..fb35dcc7 100644 --- a/ui/widgets/upnext/index.html +++ b/ui/widgets/upnext/index.html @@ -73,7 +73,7 @@