From 90c78ff94cab9e9c4939df0ad71515523ef4fda4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 08:54:25 +0000 Subject: [PATCH 1/8] app: every card is signed with a name; the tabs wait for the archive strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Signing without a title, in the editor or the import, puts the cursor in the title field, shakes it and says why a name matters; the button is not hidden. A card that arrives without one (the format allows it) is «Untitled» everywhere — one helper, cardTitle() — in italics, never a blank or a dash. The editor's placeholder asks for a name instead of looking like one. - The tab bar stays away while cards fly to the archive strip and while the strip shows where they landed; it comes back once the strip has gone down, instead of covering the landing. - shake() is shared (dom.js). Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 11 ++++++++ tests/e2e/app.test.mjs | 50 +++++++++++++++++++++++++++++++++++++ web/css/app.css | 5 ++++ web/css/collection.css | 2 -- web/src/collection/back.js | 12 +++------ web/src/collection/moves.js | 8 +++--- web/src/collection/share.js | 8 +++--- web/src/collection/view.js | 16 +++++++++--- web/src/dom.js | 8 ++++++ web/src/editor/view.js | 9 ++++++- web/src/i18n.js | 6 +++-- web/src/ui/arrival.js | 3 ++- web/src/ui/card.js | 11 ++++++-- web/src/ui/import.js | 7 +++++- 14 files changed, 128 insertions(+), 28 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 2f247da..43b01ba 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -238,6 +238,17 @@ «Поделиться» устройства, иначе буфер обмена, иначе файл), очистить; текст отчёта можно прочитать целиком до отправки. Непойманная ошибка один раз за запуск говорит о себе тостом со ссылкой на журнал. +- **У каждой своей карточки есть название.** Подписать без него нельзя ни + в редакторе, ни в импорте: кнопка «Подписать» не прячется, а ставит + курсор в поле названия, встряхивает его и говорит, зачем оно (название + остаётся с карточкой у всех, к кому она попадёт). Формат пустое название + допускает — карточка из другого приложения или старая; такая везде + показывается как «Без названия» (курсивом, приглушённо), а не пустым + местом или прочерком. +- Панель вкладок уступает место полосе архива: пока карточка летит в + архив и пока полоса показывает, куда она легла, вкладки внизу не + появляются; возвращаются, когда полоса ушла. Одно уходит — другое + приходит, ничто не садится поверх. - **Ссылка на карточку** — для того, кого нет рядом: в листе «Поделиться» под кодом кнопка «Отправить ссылкой» (лист «Поделиться» устройства, иначе буфер обмена). Ссылка — адрес приложения, карточка после `#` (на сервер не diff --git a/tests/e2e/app.test.mjs b/tests/e2e/app.test.mjs index 21d1074..0fb3592 100644 --- a/tests/e2e/app.test.mjs +++ b/tests/e2e/app.test.mjs @@ -944,3 +944,53 @@ test('a card sent as a link opens the app and arrives, even before the first run assert.equal(await cards(b), 1); for (const p of [a, b, c]) await p.context().close(); }); + +test('a card is signed with a name; one that came without shows «Без названия»', async () => { + const p = await env.phone('pixelfox'); + // The import: the title emptied, signing asks for one instead. + await tab(p, 'import'); + await choose(p, () => p.click('.import-preview'), await heart(env.dir)); + await p.waitForFunction(() => document.querySelector('.import-body input:not([type])')?.value === 'heart-x6'); + await p.fill('.import-body input:not([type])', ' '); + await p.click('text=Подписать и добавить'); + assert.match(await toast(p), /Сначала назовите карточку/); + assert.equal(await p.evaluate(() => document.activeElement?.matches('.import-body input:not([type])')), true, 'the title field, ready to type'); + assert.equal(await cards(p), 0); + // The editor: the same. + await tab(p, 'draw'); + await p.locator('.ed-art').waitFor(); + await p.click('.ed-sign'); + assert.match(await textMatching(p, '.toast span', /назовите/), /Сначала назовите карточку/); + assert.equal(await p.evaluate(() => document.activeElement?.classList.contains('ed-title')), true); + assert.equal(await cards(p), 0); + + // A card without a name from elsewhere (the format allows it): named for the eye. + await p.click('[aria-label="Закрыть редактор"]'); + await p.evaluate(async () => { + const s = await import('/src/state.js'); + const other = s.app.core.Identity.fromSeed(crypto.getRandomValues(new Uint8Array(32))); + const card = { width: 8, height: 8, opaque: true, palette: { builtin: 0 }, frame: 0, frames: 1, delay_ms: 0, title: '', author: 'kitsu', handle: null, link: '', pixels: new Uint8Array(64).fill(3) }; + await s.receive(s.app.core.openEnvelope(other.sealAsAuthor(other.signCard(card), undefined))); + }); + await tab(p, 'collection'); + await p.waitForTimeout(600); + assert.equal(await p.textContent('.on-stage .cap b'), 'Без названия'); + assert.ok(await p.$eval('.on-stage .cap b', (b) => b.classList.contains('untitled'))); + await p.context().close(); +}); + +test('the tab bar waits for the card to land in the archive and the strip to go', async () => { + const p = await env.phone('pixelfox'); + await ownCard(p, 'Последняя'); + await act(p, 'down'); // the only card: to the archive, from the feed + const away = () => p.locator('nav.tabs').evaluate((n) => n.classList.contains('away')); + // Through the flight and while the strip shows where it landed, the tabs stay out of its way… + for (const ms of [300, 700, 900]) { + await p.waitForTimeout(ms); + assert.equal(await away(), true, `still away after ${ms} ms more`); + } + // …and come back once the strip has gone down. + await p.waitForFunction(() => !document.querySelector('nav.tabs')?.classList.contains('away'), null, { timeout: 4000 }); + assert.equal(await p.evaluate(async () => [...(await import('/src/state.js')).app.cards.values()][0].rec.state), 'archive'); + await p.context().close(); +}); diff --git a/web/css/app.css b/web/css/app.css index 5813e29..ad9e7f5 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -223,6 +223,8 @@ a.btn { text-decoration: none; } /* An empty frame waiting for a picture: soft fill, like a passe-partout. */ .frame.blank .art { background: color-mix(in srgb, var(--shade) 72%, #9a8f7c); } .frame.blank .cap b { color: var(--shade-dim); font-weight: 500; } +/* A card that came without a name (another app, an old card). */ +.cap b.untitled, h3.untitled { font-style: italic; font-weight: 500; opacity: .7; } .import-empty { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: color-mix(in srgb, var(--shade-ink) 70%, transparent); text-align: center; padding: 8%; } .import-empty .i { width: 30%; height: auto; stroke-width: 1.2; opacity: .7; } @@ -247,6 +249,9 @@ a.btn { text-decoration: none; } .hint.warn-text { color: var(--warn); } /* A hint that leads into the controls under it, right below its heading. */ .hint.lead { margin: 0 2px 10px; } +/* dom.js shake(): "not like that". */ +.shake { animation: shake .32s; } +@keyframes shake { 20%, 60% { transform: translateX(-4px); } 40%, 80% { transform: translateX(4px); } } /* Folded away at the end of Profile: a quiet line until opened. */ .dev-gate summary { cursor: pointer; list-style: none; font-size: 12px; font-weight: 500; color: var(--text3); letter-spacing: .02em; padding: 4px 0; } .dev-gate summary::-webkit-details-marker { display: none; } diff --git a/web/css/collection.css b/web/css/collection.css index a32a210..4bbc83a 100644 --- a/web/css/collection.css +++ b/web/css/collection.css @@ -119,8 +119,6 @@ input.tag.add::placeholder { color: var(--shade-dim); } .back-in textarea[readonly] { pointer-events: none; } .back-in textarea:focus-visible { outline: 2px solid color-mix(in srgb, var(--shade-ink) 30%, transparent); outline-offset: 0; } .back-in .tech { font-size: .78em; color: var(--shade-dim); } -.shake { animation: shake .32s; } -@keyframes shake { 20%, 60% { transform: translateX(-4px); } 40%, 80% { transform: translateX(4px); } } /* ----------------------------------------------------- search panel -- */ .search-panel { position: absolute; z-index: 30; left: 0; right: 0; top: 0; padding: 0 max(16px, calc((100% - 1100px) / 2 + 16px)); will-change: transform; } diff --git a/web/src/collection/back.js b/web/src/collection/back.js index e1f1671..40bcf6e 100644 --- a/web/src/collection/back.js +++ b/web/src/collection/back.js @@ -7,19 +7,13 @@ // tags (the rest behind a "+N" chip), and a ruled box that // takes what fits and no more. -import { h, icon, fitBytes } from '../dom.js'; +import { h, icon, fitBytes, shake } from '../dom.js'; import { t, date } from '../i18n.js'; +import { cardTitle } from '../ui/card.js'; import { app, setNote, setTags, daysLeft, NOTE_BYTES } from '../state.js'; import { toast, ask } from '../ui/shell.js'; import { normalTag, MAX_TAGS } from './query.js'; -/** @param {Element} el */ -const shake = (el) => { - el.classList.remove('shake'); - void (/** @type {HTMLElement} */ (el)).offsetWidth; - el.classList.add('shake'); - navigator.vibrate?.(8); -}; /** * @param {import('../state.js').Entry} entry @@ -33,7 +27,7 @@ export function cardBack(entry, o) { // face inside a 3D turn leaves stale paint behind in Firefox). let editing = o.editing; const pen = h('button', { type: 'button', class: 'pen', onclick: () => o.onEdit(!editing) }); - const head = h('header', {}, h('div', {}, h('h3', {}, card.title || '—'), h('div', { class: 'by' }, by)), pen); + const head = h('header', {}, h('div', {}, h('h3', { class: card.title ? '' : 'untitled' }, cardTitle(card)), h('div', { class: 'by' }, by)), pen); const facts = h('dl'); const fact = (/** @type {string} */ k, /** @type {any} */ v, /** @type {string} */ cls = '') => facts.append(h('dt', {}, k), h('dd', { class: cls }, v)); diff --git a/web/src/collection/moves.js b/web/src/collection/moves.js index 0f63bd2..f6c1026 100644 --- a/web/src/collection/moves.js +++ b/web/src/collection/moves.js @@ -30,8 +30,8 @@ const SERIES_MS = 5000; * host: HTMLElement, fly: HTMLElement, busy: {n: number}, * place: () => Place, sync: () => void, * selection: ReturnType, - * beforeFlight: () => void, pulling: () => boolean, - * }} o + * beforeFlight: () => void, pulling: () => boolean, stripMoved: (up: boolean) => void, + * }} o stripMoved: the strip went up or down (the tab bar keeps out of its way) */ export function createMoves(o) { const { stage, strip } = o; @@ -42,11 +42,13 @@ export function createMoves(o) { // The strip is up while a card is pulled down, while cards fly to it and // a moment after they landed — unless another pull or flight took it on. let stripTimer = 0; + let stripUp = false; /** @param {number} f @param {boolean} [anim] */ function showStrip(f, anim = false) { clearTimeout(stripTimer); strip.reveal(f, anim); + if (stripUp !== f > 0) { stripUp = f > 0; o.stripMoved(stripUp); } } /** @param {number} [room] @param {string} [landed] @param {Place} [target] */ @@ -124,7 +126,7 @@ export function createMoves(o) { o.selection.stop(); o.sync(); moved(list.map((c) => c.id), from, to); - stripTimer = window.setTimeout(() => { if (!o.pulling() && !o.busy.n) strip.reveal(0, true); }, 1300); + stripTimer = window.setTimeout(() => { if (!o.pulling() && !o.busy.n) showStrip(0, true); }, 1300); }, start - now + (list.length - 1) * gap + dur + 40); } diff --git a/web/src/collection/share.js b/web/src/collection/share.js index 325269f..3f1a860 100644 --- a/web/src/collection/share.js +++ b/web/src/collection/share.js @@ -14,7 +14,7 @@ import { qrFor, keepAwake, codeSize } from '../ui/qr.js'; import { editionPanel } from '../ui/edition.js'; import { segmented } from '../kit/segmented.js'; import { topSheet } from '../kit/top-sheet.js'; -import { FRAME_SHADES } from '../ui/card.js'; +import { FRAME_SHADES, cardTitle } from '../ui/card.js'; /** * Fills `body` and returns a function that stops what keeps running @@ -39,11 +39,11 @@ export function fillShare(body, entry) { code.replaceChildren(q.el, h('p', {}, note), h('p', { class: 'small' }, q.plan.kind === 'single' ? t('give.single', env.length) : t('give.carousel', q.plan.fragments)), - h('button', { type: 'button', class: 'share-link', onclick: () => sendLink(env, entry.card.title) }, icon('link'), t('share.link'))); + h('button', { type: 'button', class: 'share-link', onclick: () => sendLink(env, cardTitle(entry.card)) }, icon('link'), t('share.link'))); }; const copy = () => showCode(app.identity.sealAsAuthor(entry.rec.payload, undefined), t('share.hint')); - const title = h('b', {}, entry.card.title || '—'); + const title = h('b', {}, cardTitle(entry.card)); if (entry.rec.mine) { const mode = segmented({ label: t('share.title'), value: 'copy', @@ -77,7 +77,7 @@ async function sendLink(env, title) { const url = app.core.cardLink(linkBase(location), env); if (navigator.share) { try { - await navigator.share({ title: title || 'Pips', text: t('share.link.text', title || '—'), url }); + await navigator.share({ title, text: t('share.link.text', title), url }); return; } catch (e) { if (/** @type {any} */ (e)?.name === 'AbortError') return; // the person changed their mind diff --git a/web/src/collection/view.js b/web/src/collection/view.js index c7a75f5..033cd3c 100644 --- a/web/src/collection/view.js +++ b/web/src/collection/view.js @@ -146,6 +146,7 @@ export function collectionView(o) { place: () => filter.place, sync, beforeFlight: () => { sheet.follow(0, true); sheet.drop(); }, pulling: () => pulling, + stripMoved: (up) => { stripUp = up; paintTabs(); }, }); // ------------------------------------------------------------- the cards -- @@ -285,7 +286,17 @@ export function collectionView(o) { } // ----------------------------------------------------------------- chrome -- - let tabsAway = false, drawnLevel = -1; + let tabsAway = false, drawnLevel = -1, stripUp = false; + /** + * The tab bar steps aside for whatever needs the bottom: a card close up, + * a selection, another place — and the strip, while cards fly to it and + * a moment after they land, so a card is never seen landing under the + * tabs. They come back once it has gone down. @param {number} [z] + */ + function paintTabs(z = stage.Z.v) { + const away = z > 1.4 || selection.active || filter.place !== 'collection' || stripUp; + if (away !== tabsAway) { tabsAway = away; if (!el.hidden) hideTabs(away); } + } /** Things that follow the zoom every frame: cheap writes only. @param {number} z */ function drawn(z) { const c = stage.current(); @@ -305,8 +316,7 @@ export function collectionView(o) { if (prevBtn.el.disabled !== i <= 0) prevBtn.el.disabled = i <= 0; if (nextBtn.el.disabled !== i >= n - 1) nextBtn.el.disabled = i >= n - 1; } - const away = z > 1.4 || selection.active || filter.place !== 'collection'; - if (away !== tabsAway) { tabsAway = away; if (!el.hidden) hideTabs(away); } + paintTabs(z); const lv = Math.round(z); if (lv !== drawnLevel) { drawnLevel = lv; paintChrome(); } } diff --git a/web/src/dom.js b/web/src/dom.js index 523250a..1b76cb7 100644 --- a/web/src/dom.js +++ b/web/src/dom.js @@ -143,3 +143,11 @@ export function reveal(box, child) { if (c.left < b.left) box.scrollLeft -= b.left - c.left; else if (c.right > b.right) box.scrollLeft += c.right - b.right; } + +/** A short shake (and a tick of vibration): "not like that". @param {Element} el */ +export function shake(el) { + el.classList.remove('shake'); + void (/** @type {HTMLElement} */ (el)).offsetWidth; // restart the animation + el.classList.add('shake'); + navigator.vibrate?.(8); +} diff --git a/web/src/editor/view.js b/web/src/editor/view.js index a421f6d..fa0da8b 100644 --- a/web/src/editor/view.js +++ b/web/src/editor/view.js @@ -20,7 +20,7 @@ // This file puts them together, keeps them in step with the picture, // keeps the draft, and signs. -import { h, icon, byteLimit } from '../dom.js'; +import { h, icon, byteLimit, shake } from '../dom.js'; import { t, date } from '../i18n.js'; import { app, addOwn } from '../state.js'; import { getMeta, setMeta } from '../store.js'; @@ -352,6 +352,13 @@ export function editorView() { stop(); const r = toCard(model.doc, by()); if (!('card' in r)) return toast(t('ed.budget.colors.why')); + // A card keeps its name for good, with everyone it reaches: ask for one + // where it is written, rather than sign it nameless. + if (!r.card.title) { + title.focus(); + shake(title); + return toast(t('card.need-title')); + } try { const added = await addOwn(app.identity.signCard(r.card)); await setMeta(DRAFT, null); diff --git a/web/src/i18n.js b/web/src/i18n.js index cf34216..bd82e30 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.js @@ -202,7 +202,8 @@ const ru = { 'back.more': (/** @type {number} */ n) => `ещё ${n} ${plural(n, 'работа', 'работы', 'работ')} автора у вас →`, 'share.title': 'Поделиться', 'share.hint': 'Пусть другой телефон отсканирует код. Смахните вверх, чтобы убрать.', // The editor. - 'ed.close': 'Закрыть редактор', 'ed.sign': 'Подписать', 'ed.title': 'Название карточки', 'ed.title.ph': 'Без названия', + 'ed.close': 'Закрыть редактор', 'ed.sign': 'Подписать', 'ed.title': 'Название карточки', 'ed.title.ph': 'Как назвать карточку', + 'card.untitled': 'Без названия', 'card.need-title': 'Сначала назовите карточку: название останется с ней у всех, к кому она попадёт', 'ed.tools': 'Инструменты', 'ed.tool.pen': 'Карандаш', 'ed.tool.eraser': 'Ластик', 'ed.tool.fill': 'Заливка', 'ed.tool.shapes': 'Линия и фигуры', 'ed.tool.pick': 'Пипетка', 'ed.tool.select': 'Выделение', 'ed.pen.solid': 'Карандаш', 'ed.pen.dither': 'Шахматкой (дизеринг)', @@ -455,7 +456,8 @@ const en = { 'back.more': (/** @type {number} */ n) => `${n} more by this artist →`, 'share.title': 'Share', 'share.hint': 'Let the other phone scan the code. Swipe up to put it away.', // The editor. - 'ed.close': 'Close the editor', 'ed.sign': 'Sign', 'ed.title': 'Card title', 'ed.title.ph': 'Untitled', + 'ed.close': 'Close the editor', 'ed.sign': 'Sign', 'ed.title': 'Card title', 'ed.title.ph': 'Name the card', + 'card.untitled': 'Untitled', 'card.need-title': 'Name the card first: the name stays with it, for everyone it reaches', 'ed.tools': 'Tools', 'ed.tool.pen': 'Pencil', 'ed.tool.eraser': 'Eraser', 'ed.tool.fill': 'Fill', 'ed.tool.shapes': 'Line and shapes', 'ed.tool.pick': 'Pipette', 'ed.tool.select': 'Selection', 'ed.pen.solid': 'Pencil', 'ed.pen.dither': 'Checkered (dither)', diff --git a/web/src/ui/arrival.js b/web/src/ui/arrival.js index aeddd6e..3c2c8fc 100644 --- a/web/src/ui/arrival.js +++ b/web/src/ui/arrival.js @@ -8,6 +8,7 @@ import { t } from '../i18n.js'; import { app, receive } from '../state.js'; import { go, toast } from './shell.js'; import { caught } from '../diagnostics.js'; +import { cardTitle } from './card.js'; /** Core error codes meaning "well-formed, but a signature does not check out". */ export const FORGED = new Set(['bad_signature', 'bad_key', 'not_author', 'bad_edition', 'unsigned']); @@ -45,7 +46,7 @@ export function peek(envelope) { try { const o = app.core.openEnvelope(envelope); const card = app.core.inspectCard(o.payload).card; - return { title: card.title || '—', author: card.author || '' }; + return { title: cardTitle(card), author: card.author || '' }; } catch { return null; } diff --git a/web/src/ui/card.js b/web/src/ui/card.js index 0a8f3de..6563673 100644 --- a/web/src/ui/card.js +++ b/web/src/ui/card.js @@ -3,7 +3,7 @@ // collection, is collection/back.js.) import { h } from '../dom.js'; -import { date } from '../i18n.js'; +import { t, date } from '../i18n.js'; import { app } from '../state.js'; /** A blank card: the exact frame size of an empty preview, or how large a profile makes a code. */ @@ -51,6 +51,13 @@ function applyShade(el, shade) { /** @param {any} card */ const byline = (card) => (card.author ? `@${card.author}` : '—'); +/** + * A card's title as shown. The app gives every card it signs a name, but + * the format allows none (another app, an old card): such a card is + * called «Untitled» everywhere, never shown blank. @param {any} card + */ +export const cardTitle = (card) => card.title || t('card.untitled'); + /** * A card frame. * @param {import('../state.js').Entry} entry @@ -60,7 +67,7 @@ export function frame(entry, opts = {}) { const { card, rec } = entry; const front = h('div', { class: 'face front' }, h('div', { class: 'art' }, artCanvas(card, rec.digest)), - h('div', { class: 'cap' }, h('b', {}, card.title || '—'), h('i', {}, `${byline(card)} · ${date(rec.received, { day: '2-digit', month: '2-digit' })}`))); + h('div', { class: 'cap' }, h('b', { class: card.title ? '' : 'untitled' }, cardTitle(card)), h('i', {}, `${byline(card)} · ${date(rec.received, { day: '2-digit', month: '2-digit' })}`))); const flip = h('div', { class: 'flip' }, front); const el = h('div', { class: `frame${opts.bare ? ' bare' : ''}` }, flip); applyShade(el, card.frame); diff --git a/web/src/ui/import.js b/web/src/ui/import.js index 68bb195..30be1e2 100644 --- a/web/src/ui/import.js +++ b/web/src/ui/import.js @@ -10,7 +10,7 @@ // and passes the options back. Each change re-runs the import from the // original bytes, so options never compound. -import { h, icon, byteLimit, fitBytes } from '../dom.js'; +import { h, icon, byteLimit, fitBytes, shake } from '../dom.js'; import { t, reason } from '../i18n.js'; import { app, addOwn } from '../state.js'; import { frame, FRAME_SHADES, BLANK_CARD } from './card.js'; @@ -255,6 +255,11 @@ export function importView() { async function sign() { if (!result || save.disabled) return; + if (!title.value.trim()) { + title.focus(); + shake(title.closest('.field') ?? title); + return toast(t('card.need-title')); + } const p = app.profile; const card = { ...result.card, title: title.value.trim(), frame: shade, author: p.nick, handle: p.handle, link: p.link }; try { From e2aacbc641c30c6bea5a0423fd4272f9a3e04e33 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:17:58 +0000 Subject: [PATCH 2/8] editor: the grid follows the card wherever the layout puts it The grid, cursor and selection are drawn on one canvas from the card's place in the layout, recomputed only when the window resized. The editor settles into its layout after it is first drawn, and the grid stayed where the card had been: 43px too low on a phone, short of the bottom edge and starting a row down. A ResizeObserver on the viewport, the card and the art now redraws them on any change. A test checks the grid's lines meet the art's edges on a phone and a computer. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/app.test.mjs | 24 ++++++++++++++++++++++++ web/src/editor/canvas.js | 6 ++++++ 2 files changed, 30 insertions(+) diff --git a/tests/e2e/app.test.mjs b/tests/e2e/app.test.mjs index 0fb3592..3872b60 100644 --- a/tests/e2e/app.test.mjs +++ b/tests/e2e/app.test.mjs @@ -994,3 +994,27 @@ test('the tab bar waits for the card to land in the archive and the strip to go' assert.equal(await p.evaluate(async () => [...(await import('/src/state.js')).app.cards.values()][0].rec.state), 'archive'); await p.context().close(); }); + +test('the editor grid lies exactly on the cells, from edge to edge', async () => { + for (const desk of [false, true]) { + const p = await env.phone('pixelfox', undefined, desk); + await tab(p, 'draw'); + await p.locator('.ed-art').waitFor(); + await p.waitForTimeout(400); + const m = await p.evaluate(() => { + const art = document.querySelector('.ed-art').getBoundingClientRect(); + const lines = /** @type {HTMLCanvasElement} */ (document.querySelector('.ed-lines')); + const v = lines.getBoundingClientRect(), d = lines.getContext('2d').getImageData(0, 0, lines.width, lines.height).data; + let x0 = Infinity, y0 = Infinity, x1 = -1, y1 = -1; + for (let y = 0; y < lines.height; y++) for (let x = 0; x < lines.width; x++) { + if (!d[(y * lines.width + x) * 4 + 3]) continue; + x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); + } + const k = devicePixelRatio; + return { art: [art.left - v.left, art.top - v.top, art.right - v.left, art.bottom - v.top], grid: [x0 / k, y0 / k, (x1 + 1) / k, (y1 + 1) / k] }; + }); + // The grid's lines start and end on the art's edges (within a pixel). + m.art.forEach((a, i) => assert.ok(Math.abs(a - m.grid[i]) <= 1.5, `${desk ? 'computer' : 'phone'}: ${JSON.stringify(m)}`)); + await p.context().close(); + } +}); diff --git a/web/src/editor/canvas.js b/web/src/editor/canvas.js index 5799e40..5869f85 100644 --- a/web/src/editor/canvas.js +++ b/web/src/editor/canvas.js @@ -160,6 +160,12 @@ export function createCanvas() { viewport = v; target = t; v.append(lines); + // The lines are drawn from the card's place in the layout, so they + // follow any change of it — not only the window's: the title's font + // arriving, the palette or the frames below growing, the editor + // opening into its final size. Otherwise the grid sits off the cells. + const follow = new ResizeObserver(() => place()); + for (const e of [v, t, el]) follow.observe(e); }, /** * Draws a frame. @param {import('./model.js').Doc} doc @param {number} f From 5aaeafd88b5de02a14e2c2d383ba2af51b0e7a70 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:21:37 +0000 Subject: [PATCH 3/8] tests: swap two recovery words the checksum is sure to catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checksum is 8 bits, so one swap in 256 still adds up — which CI just met. The test now picks a pair the core itself rejects. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/app.test.mjs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/e2e/app.test.mjs b/tests/e2e/app.test.mjs index 3872b60..6b353ea 100644 --- a/tests/e2e/app.test.mjs +++ b/tests/e2e/app.test.mjs @@ -85,7 +85,20 @@ test('restoring on a new phone: the backup and the key in one step', async () => await b.fill('.words-entry input >> nth=5', 'qqq'); assert.match(await status(b), /Слова № 6 нет в списке/); assert.equal(await b.getAttribute('.words-entry input >> nth=5', 'aria-invalid'), 'true'); - await b.fill('.words-entry input >> nth=5', `${list[6]} ${list[5]}`); // two words, swapped, from cell 6 on + // Two words swapped. The checksum is 8 bits: one swap in 256 still adds + // up, so the pair is one the core itself rejects. + const k = await b.evaluate(async (w) => { + const { app } = await import('/src/state.js'); + for (let i = 5; i < 23; i++) { + if (w[i] === w[i + 1]) continue; + const s = [...w]; + [s[i], s[i + 1]] = [s[i + 1], s[i]]; + try { app.core.Identity.fromWords(s.join(' ')); } catch { return i; } + } + return -1; + }, list); + assert.ok(k >= 5); + await b.fill('.words-entry input >> nth=5', list.slice(5, k).concat([list[k + 1], list[k]]).join(' ')); // from cell 6 on assert.match(await status(b), /не сходятся/); assert.equal(await go(b), false); // Four letters of each word are enough; they are written out in full. From 6284d43abd3b5b83813bbc083e474d9a6adc3a5d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:25:22 +0000 Subject: [PATCH 4/8] tests: the other import test waits for the file to name the card too The same race as before, in the test of an import kept across tabs. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/app.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/app.test.mjs b/tests/e2e/app.test.mjs index 6b353ea..c9dc586 100644 --- a/tests/e2e/app.test.mjs +++ b/tests/e2e/app.test.mjs @@ -179,6 +179,7 @@ test('an import under way survives a look at another tab', async () => { const p = await env.phone('pixelfox'); await tab(p, 'import'); await choose(p, () => p.click('.import-preview'), await heart(env.dir)); + await p.waitForFunction(() => document.querySelector('.import-body input:not([type])')?.value === 'heart-x6'); // read, and named after the file await p.locator('.import-body input:not([type])').fill('Сердце'); await tab(p, 'receive'); await tab(p, 'collection'); From 7ccd9175fbca35d05559a3fc55328b1c41144c91 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:36:01 +0000 Subject: [PATCH 5/8] tests: the tab bar is checked by order, not by a stopwatch The test slept fixed times and failed on a slower CI machine. It now records, in the page, when the archive strip goes down and when the tabs come back, and checks only that the second is not before the first. It still fails on the code from before the fix. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/app.test.mjs | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/e2e/app.test.mjs b/tests/e2e/app.test.mjs index c9dc586..088a0b2 100644 --- a/tests/e2e/app.test.mjs +++ b/tests/e2e/app.test.mjs @@ -996,15 +996,26 @@ test('a card is signed with a name; one that came without shows «Без наз test('the tab bar waits for the card to land in the archive and the strip to go', async () => { const p = await env.phone('pixelfox'); await ownCard(p, 'Последняя'); + // Watch the page itself, not a stopwatch: when the strip goes down, and + // when the tabs come back. Timings differ from machine to machine; the + // order must not. + await p.evaluate(() => { + const w = /** @type {any} */ (window), tabs = document.querySelector('nav.tabs'), strip = document.querySelector('.strip'); + w.seen = { stripDown: 0, tabsBack: 0, flying: false }; + new MutationObserver(() => { if (!tabs?.classList.contains('away') && !w.seen.tabsBack && w.seen.flying) w.seen.tabsBack = performance.now(); }) + .observe(/** @type {Element} */ (tabs), { attributes: true }); + new MutationObserver(() => { + const up = !/101%/.test(/** @type {HTMLElement} */ (strip).style.transform); + if (up) w.seen.flying = true; + else if (w.seen.flying && !w.seen.stripDown) w.seen.stripDown = performance.now(); + }).observe(/** @type {Element} */ (strip), { attributes: true, attributeFilter: ['style'] }); + }); await act(p, 'down'); // the only card: to the archive, from the feed - const away = () => p.locator('nav.tabs').evaluate((n) => n.classList.contains('away')); - // Through the flight and while the strip shows where it landed, the tabs stay out of its way… - for (const ms of [300, 700, 900]) { - await p.waitForTimeout(ms); - assert.equal(await away(), true, `still away after ${ms} ms more`); - } - // …and come back once the strip has gone down. - await p.waitForFunction(() => !document.querySelector('nav.tabs')?.classList.contains('away'), null, { timeout: 4000 }); + await p.waitForFunction(() => /** @type {any} */ (window).seen.tabsBack > 0, null, { timeout: 6000 }); + const seen = await p.evaluate(() => /** @type {any} */ (window).seen); + assert.ok(seen.stripDown > 0, 'the strip showed, then went down'); + // (In the same moment counts: both change in one step.) + assert.ok(seen.tabsBack >= seen.stripDown - 5, `the tabs came back only as the strip went (${JSON.stringify(seen)})`); assert.equal(await p.evaluate(async () => [...(await import('/src/state.js')).app.cards.values()][0].rec.state), 'archive'); await p.context().close(); }); From 1541c3b195cacfcec885bd078bb561c02c8cb090 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 09:59:08 +0000 Subject: [PATCH 6/8] editor, collection: the pen keeps what it drew; animated cards play MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The pencil smooths a line by taking out the cell that only turns a corner. It also took out cells the same stroke had already drawn — when the finger went back over its line or crossed it — leaving holes. It now counts how much of the stroke covers each cell and undoes only cells nothing else covers. - A second pencil keeps every cell (corners drawn on purpose stay). The pen menu: smoothing, square, checkered. - Animated cards never played in the collection. Now the card looked at — in the middle of the feed, or open — plays, one at a time; the grid and the strip hold the first frame, and an animated card carries a small play mark there. Reduced motion: nothing plays. The back of a card says how many frames. Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 11 ++++++++ tests/e2e/app.test.mjs | 30 ++++++++++++++++++++ tests/unit/editor.test.mjs | 43 +++++++++++++++++++++++++++++ web/README.md | 1 + web/css/app.css | 7 +++++ web/src/collection/back.js | 2 +- web/src/collection/view.js | 5 +++- web/src/dom.js | 1 + web/src/editor/strokes.js | 24 +++++++++++++--- web/src/editor/tools.js | 11 ++++---- web/src/i18n.js | 10 ++++--- web/src/ui/card.js | 5 ++-- web/src/ui/import.js | 3 +- web/src/ui/player.js | 56 ++++++++++++++++++++++++++++++++++++++ 14 files changed, 191 insertions(+), 18 deletions(-) create mode 100644 web/src/ui/player.js diff --git a/DECISIONS.md b/DECISIONS.md index 43b01ba..814b756 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -238,6 +238,17 @@ «Поделиться» устройства, иначе буфер обмена, иначе файл), очистить; текст отчёта можно прочитать целиком до отправки. Непойманная ошибка один раз за запуск говорит о себе тостом со ссылкой на журнал. +- **Карандаш — в двух видах.** Обычный сглаживает линию (pixel-perfect): + клетка, которая только поворачивает угол, убирается — поэтому на миг + появляется и исчезает, когда палец сворачивает. Он никогда не убирает + клетку, которую тот же штрих уже нарисовал (возврат по своей линии, + пересечение) — это было ошибкой. Второй вид — «каждая клетка, углы как + есть», для углов, нарисованных нарочно. Оба — в меню карандаша (второе + касание), вместе с шахматкой. +- **Анимированные карточки играют там, куда смотрят**: в центре ленты и + открытая — по одной за раз. В сетке и полосе архива — первый кадр (ради + батареи) и маленький знак «▶» в углу. При «уменьшить движение» в системе + не играют нигде. - **У каждой своей карточки есть название.** Подписать без него нельзя ни в редакторе, ни в импорте: кнопка «Подписать» не прячется, а ставит курсор в поле названия, встряхивает его и говорит, зачем оно (название diff --git a/tests/e2e/app.test.mjs b/tests/e2e/app.test.mjs index 088a0b2..8e27c77 100644 --- a/tests/e2e/app.test.mjs +++ b/tests/e2e/app.test.mjs @@ -1043,3 +1043,33 @@ test('the editor grid lies exactly on the cells, from edge to edge', async () => await p.context().close(); } }); + +test('an animated card holds still in the grid, with a mark, and plays when looked at', async () => { + const p = await env.phone('pixelfox'); + await tab(p, 'me'); + await samples(p); + await p.waitForTimeout(1200); + await tab(p, 'collection'); + await p.waitForTimeout(800); + const id = await p.evaluate(async () => [...(await import('/src/state.js')).app.cards.values()].find((e) => e.card.title === 'Кораблик')?.rec.digest); + const art = `.on-stage .art canvas[data-art="${id}"]`; + const pixels = () => p.$eval(art, (cv) => /** @type {HTMLCanvasElement} */ (cv).toDataURL()); + // The grid: the first frame, still, and the mark in the corner. + assert.ok(await p.isVisible(`.on-stage .art:has(${art.split(' ').pop()}) .anim-mark`), 'marked as animated'); + const still = await pixels(); + await p.waitForTimeout(800); + assert.equal(await pixels(), still, 'still in the grid'); + // Looked at: it plays (the boat rocks, the waves move). + await p.locator(art).scrollIntoViewIfNeeded(); + const box = await p.locator(art).boundingBox(); + await p.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await p.waitForTimeout(1000); + const seen = new Set(); + for (let k = 0; k < 6; k++) { seen.add(await pixels()); await p.waitForTimeout(170); } + assert.ok(seen.size > 1, 'it plays in the feed'); + // Back to the grid: still again, on its first frame. + await p.keyboard.press('Escape'); + await p.waitForTimeout(900); + assert.equal(await pixels(), still, 'the first frame again'); + await p.context().close(); +}); diff --git a/tests/unit/editor.test.mjs b/tests/unit/editor.test.mjs index 3fc860e..d3ada38 100644 --- a/tests/unit/editor.test.mjs +++ b/tests/unit/editor.test.mjs @@ -199,3 +199,46 @@ test('copy numbers: two logs of one card are joined, never chosen between', asyn assert.equal(joinLogs(undefined, b).of, 10, 'a run fixed by the other log'); assert.equal(joinLogs({ of: 0, issued: [] }, b).of, 10); }); + +test('the pen: corners come out of a curve, never out of what was already drawn', async () => { + const { createStrokes } = await import('../../web/src/editor/strokes.js'); + const setup = (axes = { x: false, y: false }, pen = 'solid') => { + const m = createModel(blank(8, PAL)); + const strokes = createStrokes({ + model: m, pick() {}, selection: /** @type {any} */ ({}), + now: () => ({ frame: 0, color: 1, shape: 'line', pen, mirror: axes.x || axes.y, axes }), + }); + const at = (/** @type {number} */ x, /** @type {number} */ y) => m.doc.frames[0][y * 8 + x]; + return { m, strokes, at }; + }; + const draw = (/** @type {any} */ strokes, /** @type {number[][]} */ cells) => { + const hnd = strokes.start({ x: cells[0][0], y: cells[0][1] }, 'pen'); + for (const [x, y] of cells.slice(1)) hnd.move({ x, y }); + hnd.end(); + }; + + // A staircase: the cells that only turn a corner go (pixel-perfect). + let { strokes, at } = setup(); + draw(strokes, [[0, 0], [1, 0], [1, 1], [2, 1], [2, 2]]); + assert.deepEqual([at(0, 0), at(1, 0), at(1, 1), at(2, 1), at(2, 2)], [1, T, 1, T, 1]); + + // Along a line and back, then off it: the line keeps every cell. + ({ strokes, at } = setup()); + draw(strokes, [[0, 3], [1, 3], [2, 3], [3, 3], [2, 3], [2, 4]]); + assert.deepEqual([at(0, 3), at(1, 3), at(2, 3), at(3, 3)], [1, 1, 1, 1], 'no hole where the finger turned back'); + + // Crossing an earlier part of the stroke at a corner: no hole there either. + ({ strokes, at } = setup()); + draw(strokes, [[1, 1], [2, 1], [3, 1], [3, 2], [3, 3], [2, 3], [2, 2], [2, 1], [1, 1], [1, 0]]); + assert.equal(at(2, 1), 1, 'the cell where the stroke crossed itself'); + + // With the mirror, the same holds for the mirrored half. + ({ strokes, at } = setup({ x: true, y: false })); + draw(strokes, [[0, 3], [1, 3], [2, 3], [1, 3], [1, 4]]); + assert.deepEqual([at(1, 3), at(6, 3)], [1, 1]); + + // The square pen keeps every cell: a corner drawn on purpose stays. + ({ strokes, at } = setup(undefined, 'square')); + draw(strokes, [[0, 0], [1, 0], [1, 1], [2, 1], [2, 2]]); + assert.deepEqual([at(0, 0), at(1, 0), at(1, 1), at(2, 1), at(2, 2)], [1, 1, 1, 1, 1]); +}); diff --git a/web/README.md b/web/README.md index 58ec8a6..1bf8995 100644 --- a/web/README.md +++ b/web/README.md @@ -42,6 +42,7 @@ src/budget.js what a card may be to be signed, drawn src/editions.js copy-number logs: joined, and the next number src/journal.js, src/diagnostics.js the error journal: what is kept, and wiring it to the page src/links.js card links: where they point, the card in an address +src/ui/player.js the one animated card that plays: the one looked at src/version.js the version, written by scripts/build-site.sh src/dom.js DOM without innerHTML, the icon set src/kit/ the bricks: motion, gestures, buttons, fields, sheets diff --git a/web/css/app.css b/web/css/app.css index ad9e7f5..d801e0f 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -135,6 +135,13 @@ svg.i { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-widt .front { padding: 3.6cqw 3.6cqw 0; } .art { aspect-ratio: 1; background: color-mix(in srgb, var(--shade) 88%, #000); overflow: hidden; } .art canvas { display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; } +/* An animated card, held still: a small "play" in its corner. */ +.art { position: relative; } +.anim-mark { position: absolute; right: 5%; bottom: 5%; width: 9%; aspect-ratio: 1; min-width: 7px; border-radius: 50%; + background: color-mix(in srgb, var(--shade) 85%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--shade-ink) 25%, transparent); } +.art.playing .anim-mark { opacity: 0; } +.anim-mark::after { content: ''; position: absolute; inset: 28% 24% 28% 36%; background: var(--shade-ink); opacity: .75; + clip-path: polygon(0 0, 100% 50%, 0 100%); } .cap { height: calc(19cqw + 10px); min-height: 34px; display: flex; flex-direction: column; justify-content: center; overflow: hidden; } .cap b { font: 600 max(11px, 6.2cqw)/1.3 var(--serif); color: var(--shade-ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .cap i { font-style: normal; font-size: max(10px, 4.6cqw); line-height: 1.35; color: var(--shade-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } diff --git a/web/src/collection/back.js b/web/src/collection/back.js index 40bcf6e..33eaf25 100644 --- a/web/src/collection/back.js +++ b/web/src/collection/back.js @@ -46,7 +46,7 @@ export function cardBack(entry, o) { } fact(t('back.k.sign'), author ? `✓ ${t('back.sign.ok')} · ${author.short}` : t('back.sign.none'), author ? 'ok' : 'bad'); for (const l of links(card)) fact(l.k, l.v); - fact(t('back.k.pic'), t('back.tech', card.width, card.height, rec.payload.length)); + fact(t('back.k.pic'), t('back.tech', card.width, card.height, rec.payload.length, card.frames)); if (rec.state === 'trash') fact(t('back.k.bin'), t('back.bin-left', daysLeft(rec)), 'bad'); // More by the same artist (by key, not by name): a tap shows them all. const others = author && !rec.mine ? [...app.cards.values()].filter((e) => e.author?.hex === author.hex && e.rec.digest !== rec.digest).length : 0; diff --git a/web/src/collection/view.js b/web/src/collection/view.js index 033cd3c..e19545d 100644 --- a/web/src/collection/view.js +++ b/web/src/collection/view.js @@ -21,6 +21,7 @@ import { app, subscribe } from '../state.js'; import { getMeta, setMeta } from '../store.js'; import { frame, FRAME_SHADES } from '../ui/card.js'; import { go, hideTabs } from '../ui/shell.js'; +import { playOnly } from '../ui/player.js'; import { loop, clamp } from '../kit/motion.js'; import { iconButton } from '../kit/icon-button.js'; import { segmented } from '../kit/segmented.js'; @@ -310,6 +311,8 @@ export function collectionView(o) { searchPanel.style.transform = `translate3d(0,${stage.frame.top - stage.scroll[0].v}px,0)`; const inFeed = z > 1.6 && !busy.n && !stage.dragY.v && !!c; feedBar.show(inFeed); + // The card looked at plays, if it moves; in the grid, none does. + playOnly(z > 1.6 && !busy.n && c ? c.id : null); if (MOUSE) { const i = Math.round(stage.feed.to), n = stage.list.length; if (feedNav.hidden === inFeed) feedNav.hidden = !inFeed; @@ -495,6 +498,6 @@ export function collectionView(o) { stage.kick(); } }, - hide() { hideTabs(false); sheet.close(); }, + hide() { hideTabs(false); sheet.close(); playOnly(null); }, }; } diff --git a/web/src/dom.js b/web/src/dom.js index 1b76cb7..cf68d41 100644 --- a/web/src/dom.js +++ b/web/src/dom.js @@ -87,6 +87,7 @@ const ICONS = { history: '', select: '', 'select-all': '', + 'pen-square': '', dither: '', 'flip-x': '', 'flip-y': '', diff --git a/web/src/editor/strokes.js b/web/src/editor/strokes.js index 570c523..3aeb4ee 100644 --- a/web/src/editor/strokes.js +++ b/web/src/editor/strokes.js @@ -62,15 +62,31 @@ export function createStrokes(o) { const dither = tool === 'pen' && s.pen === 'dither'; /** @type {Cell[]} */ const path = []; + // How many places of the path (and their mirror images) cover each + // cell. A corner taken out is undone only where nothing else of the + // stroke covers it — not where the line crossed or doubled back over + // itself, which would cut a hole in what was just drawn. + /** @type {Map} */ + const cover = new Map(); const put = (/** @type {Cell} */ p) => { const last = path[path.length - 1]; if (last && last.x === p.x && last.y === p.y) return; path.push(p); - for (const q of spots(p, s)) if (!dither || dithered(q)) st.set(index(q), v); - // Pixel-perfect: the cell that only turns a corner goes. - if (tool === 'pen' && !dither && path.length >= 3 && corner(path[path.length - 3], path[path.length - 2], p)) { + for (const q of spots(p, s)) { + if (dither && !dithered(q)) continue; + const i = index(q); + cover.set(i, (cover.get(i) ?? 0) + 1); + st.set(i, v); + } + // Pixel-perfect: the cell that only turns a corner goes (the smoothing + // pen; the square one keeps every cell, for corners drawn on purpose). + if (tool === 'pen' && s.pen === 'solid' && path.length >= 3 && corner(path[path.length - 3], path[path.length - 2], p)) { const mid = /** @type {Cell} */ (path.splice(path.length - 2, 1)[0]); - for (const q of spots(mid, s)) st.restore(index(q)); + for (const q of spots(mid, s)) { + const i = index(q), n = (cover.get(i) ?? 1) - 1; + cover.set(i, n); + if (n <= 0) st.restore(i); + } } }; put(c); diff --git a/web/src/editor/tools.js b/web/src/editor/tools.js index 0192d3c..d71d9ba 100644 --- a/web/src/editor/tools.js +++ b/web/src/editor/tools.js @@ -1,15 +1,16 @@ // @ts-check // The tool bar: what a finger on the canvas does, and two switches — the // mirror and the precise mode. A second tap on the pen or the shapes tool -// shows its kinds: a plain pen or a dithering one; a line, rectangles, -// ellipses. +// shows its kinds: a pen that smooths the corners out of a curve (pixel- +// perfect), one that keeps every cell it passes, a dithering one; a line, +// rectangles, ellipses. import { h, icon } from '../dom.js'; import { t } from '../i18n.js'; /** @typedef {'pen'|'eraser'|'fill'|'shapes'|'select'|'pick'} Tool */ /** @typedef {'line'|'rect'|'rect-fill'|'ellipse'|'ellipse-fill'} Shape */ -/** @typedef {'solid'|'dither'} PenMode */ +/** @typedef {'solid'|'square'|'dither'} PenMode solid: pixel-perfect (corners of a curve taken out); square: every cell kept */ /** Tools with their keys (shown on a computer). */ export const TOOLS = /** @type {const} */ ([ @@ -21,7 +22,7 @@ export const TOOLS = /** @type {const} */ ([ { id: 'pick', icon: 'pick', key: 'I' }, ]); export const SHAPES = /** @type {const} */ (['line', 'rect', 'rect-fill', 'ellipse', 'ellipse-fill']); -export const PENS = /** @type {const} */ ([{ id: 'solid', icon: 'brush' }, { id: 'dither', icon: 'dither' }]); +export const PENS = /** @type {const} */ ([{ id: 'solid', icon: 'brush' }, { id: 'square', icon: 'pen-square' }, { id: 'dither', icon: 'dither' }]); /** * @param {{ @@ -68,7 +69,7 @@ export function toolBar(o) { set(s) { current = s.tool; TOOLS.forEach((tl, k) => tools[k].setAttribute('aria-pressed', String(tl.id === s.tool))); - tools[0].replaceChildren(icon(s.pen === 'dither' ? 'dither' : 'brush'), h('kbd', {}, 'B')); + tools[0].replaceChildren(icon(s.pen === 'dither' ? 'dither' : s.pen === 'square' ? 'pen-square' : 'brush'), h('kbd', {}, 'B')); tools[3].replaceChildren(icon(s.shape === 'line' ? 'shapes' : s.shape), h('kbd', {}, 'U')); for (const b of shapes.buttons) b.setAttribute('aria-pressed', String(b.dataset.kind === s.shape)); for (const b of pens.buttons) b.setAttribute('aria-pressed', String(b.dataset.kind === s.pen)); diff --git a/web/src/i18n.js b/web/src/i18n.js index bd82e30..2dad268 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.js @@ -16,7 +16,8 @@ const ru = { 'back.yours': 'ваша карточка', 'back.edition': (/** @type {number} */ n, /** @type {number} */ m) => m ? `экземпляр ${n} из ${m}` : `экземпляр №${n}`, 'back.edition-yours': 'ваш', 'back.edition-not-yours': 'выдан другому человеку', - 'back.tech': (/** @type {number} */ w, /** @type {number} */ hh, /** @type {number} */ b) => `${w}×${hh} · ${b} байт`, + 'back.tech': (/** @type {number} */ w, /** @type {number} */ hh, /** @type {number} */ b, /** @type {number} */ f) => `${w}×${hh} · ${b} байт${f > 1 ? ` · ${f} ${plural(f, 'кадр', 'кадра', 'кадров')}` : ''}`, + 'card.animated': (/** @type {number} */ n) => `Анимация: ${n} ${plural(n, 'кадр', 'кадра', 'кадров')}`, 'link.confirm': (/** @type {string} */ host) => `Открыть ссылку на ${host}? Её оставил автор карточки, Pips её не проверял.`, 'import.title': 'Новая карточка', 'import.reset': 'Начать заново', 'import.pick': 'Выбрать картинку', 'import.pick.short': 'PNG или Aseprite', 'import.toggle': 'Показать оригинал или карточку', 'import.pick.hint': 'До 128×128 пикселей и 32 цветов. Увеличенный экспорт (×2, ×4…) уменьшится сам. Файл не покидает устройство.', @@ -206,7 +207,7 @@ const ru = { 'card.untitled': 'Без названия', 'card.need-title': 'Сначала назовите карточку: название останется с ней у всех, к кому она попадёт', 'ed.tools': 'Инструменты', 'ed.tool.pen': 'Карандаш', 'ed.tool.eraser': 'Ластик', 'ed.tool.fill': 'Заливка', 'ed.tool.shapes': 'Линия и фигуры', 'ed.tool.pick': 'Пипетка', - 'ed.tool.select': 'Выделение', 'ed.pen.solid': 'Карандаш', 'ed.pen.dither': 'Шахматкой (дизеринг)', + 'ed.tool.select': 'Выделение', 'ed.pen.solid': 'Карандаш — сглаживает углы линий', 'ed.pen.square': 'Карандаш — каждая клетка, углы как есть', 'ed.pen.dither': 'Шахматкой (дизеринг)', 'ed.sel': 'Выделение', 'ed.sel.copy': 'Двигать копию', 'ed.sel.flip-x': 'Отразить слева направо', 'ed.sel.flip-y': 'Отразить сверху вниз', 'ed.sel.turn': 'Повернуть на четверть', 'ed.sel.erase': 'Стереть выделенное', 'ed.sel.all': 'Выделить всё', 'ed.sel.drop': 'Снять выделение', @@ -270,7 +271,8 @@ const en = { 'back.yours': 'your card', 'back.edition': (/** @type {number} */ n, /** @type {number} */ m) => m ? `copy ${n} of ${m}` : `copy no. ${n}`, 'back.edition-yours': 'yours', 'back.edition-not-yours': 'issued to someone else', - 'back.tech': (/** @type {number} */ w, /** @type {number} */ hh, /** @type {number} */ b) => `${w}×${hh} · ${b} bytes`, + 'back.tech': (/** @type {number} */ w, /** @type {number} */ hh, /** @type {number} */ b, /** @type {number} */ f) => `${w}×${hh} · ${b} bytes${f > 1 ? ` · ${f} frames` : ''}`, + 'card.animated': (/** @type {number} */ n) => `Animated: ${n} frames`, 'link.confirm': (/** @type {string} */ host) => `Open a link to ${host}? The artist left it; Pips has not checked it.`, 'import.title': 'New card', 'import.reset': 'Start over', 'import.pick': 'Choose a picture', 'import.pick.short': 'PNG or Aseprite', 'import.toggle': 'Show the original or the card', 'import.pick.hint': 'Up to 128×128 pixels and 32 colours. An upscaled export (×2, ×4…) shrinks back by itself. The file never leaves this device.', @@ -460,7 +462,7 @@ const en = { 'card.untitled': 'Untitled', 'card.need-title': 'Name the card first: the name stays with it, for everyone it reaches', 'ed.tools': 'Tools', 'ed.tool.pen': 'Pencil', 'ed.tool.eraser': 'Eraser', 'ed.tool.fill': 'Fill', 'ed.tool.shapes': 'Line and shapes', 'ed.tool.pick': 'Pipette', - 'ed.tool.select': 'Selection', 'ed.pen.solid': 'Pencil', 'ed.pen.dither': 'Checkered (dither)', + 'ed.tool.select': 'Selection', 'ed.pen.solid': 'Pencil — smooths the corners of a line', 'ed.pen.square': 'Pencil — every cell, corners kept', 'ed.pen.dither': 'Checkered (dither)', 'ed.sel': 'Selection', 'ed.sel.copy': 'Move a copy', 'ed.sel.flip-x': 'Mirror left to right', 'ed.sel.flip-y': 'Mirror top to bottom', 'ed.sel.turn': 'Turn a quarter', 'ed.sel.erase': 'Erase the selection', 'ed.sel.all': 'Select all', 'ed.sel.drop': 'Deselect', diff --git a/web/src/ui/card.js b/web/src/ui/card.js index 6563673..ea7f535 100644 --- a/web/src/ui/card.js +++ b/web/src/ui/card.js @@ -35,7 +35,7 @@ export function artCanvas(card, key) { img = new ImageData(new Uint8ClampedArray(r.rgba), r.width, r.height); if (key) artCache.set(key, img); } - const cv = /** @type {HTMLCanvasElement} */ (h('canvas', { width: img.width, height: img.height })); + const cv = /** @type {HTMLCanvasElement} */ (h('canvas', { width: img.width, height: img.height, dataset: key ? { art: key } : {} })); /** @type {CanvasRenderingContext2D} */ (cv.getContext('2d')).putImageData(img, 0, 0); return cv; } @@ -66,7 +66,8 @@ export const cardTitle = (card) => card.title || t('card.untitled'); export function frame(entry, opts = {}) { const { card, rec } = entry; const front = h('div', { class: 'face front' }, - h('div', { class: 'art' }, artCanvas(card, rec.digest)), + // An animated card says so in its corner: in the grid it holds still. + h('div', { class: 'art' }, artCanvas(card, rec.digest), card.frames > 1 ? h('span', { class: 'anim-mark', title: t('card.animated', card.frames) }) : ''), h('div', { class: 'cap' }, h('b', { class: card.title ? '' : 'untitled' }, cardTitle(card)), h('i', {}, `${byline(card)} · ${date(rec.received, { day: '2-digit', month: '2-digit' })}`))); const flip = h('div', { class: 'flip' }, front); const el = h('div', { class: `frame${opts.bare ? ' bare' : ''}` }, flip); diff --git a/web/src/ui/import.js b/web/src/ui/import.js index 30be1e2..ca69a6e 100644 --- a/web/src/ui/import.js +++ b/web/src/ui/import.js @@ -203,7 +203,7 @@ export function importView() { /** Plays an animated card inside its frame. @param {HTMLElement} el @param {any} card */ function animate(el, card) { - if (card.frames < 2) return; + if (card.frames < 2 || matchMedia('(prefers-reduced-motion: reduce)').matches) return; const cv = /** @type {HTMLCanvasElement|null} */ (el.querySelector('.art canvas')); if (!cv) return; const g = /** @type {CanvasRenderingContext2D} */ (cv.getContext('2d')); @@ -211,6 +211,7 @@ export function importView() { const r = app.core.render(card, i); return new ImageData(new Uint8ClampedArray(r.rgba), r.width, r.height); }); + el.querySelector('.art')?.classList.add('playing'); // it plays here: no mark let i = 0; timer = window.setInterval(() => g.putImageData(images[(i = (i + 1) % images.length)], 0, 0), Math.max(card.delay_ms, 20)); } diff --git a/web/src/ui/player.js b/web/src/ui/player.js new file mode 100644 index 0000000..825e658 --- /dev/null +++ b/web/src/ui/player.js @@ -0,0 +1,56 @@ +// @ts-check +// Animated cards play where they are looked at — the card in the middle of +// the feed, or open — one at a time. Everywhere else (the grid, the strip) +// a card shows its first frame, to spare the battery; so does every card +// for a person who asked their system for less motion. + +import { app } from '../state.js'; + +/** @type {Map} the frames of cards that played, by digest */ +const cache = new Map(); +/** @type {{id: string, timer: number} | null} */ +let playing = null; +const still = matchMedia('(prefers-reduced-motion: reduce)'); + +/** @param {string} id @param {any} card */ +function framesOf(id, card) { + let list = cache.get(id); + if (!list) { + list = Array.from({ length: card.frames }, (_, k) => { + const r = app.core.render(card, k); + return new ImageData(new Uint8ClampedArray(r.rgba), r.width, r.height); + }); + cache.set(id, list); + } + return list; +} + +/** + * Every picture of this card on screen (the feed's, the open card's); a + * playing one hides its "animated" mark. @param {string} id @param {ImageData} img @param {boolean} on + */ +function show(id, img, on) { + for (const cv of document.querySelectorAll(`.art canvas[data-art="${CSS.escape(id)}"]`)) { + /** @type {CanvasRenderingContext2D} */ (/** @type {HTMLCanvasElement} */ (cv).getContext('2d')).putImageData(img, 0, 0); + cv.parentElement?.classList.toggle('playing', on); + } +} + +/** + * Plays this card (a digest), and only it; null stops whatever plays. The + * card stopped goes back to its first frame. @param {string|null} id + */ +export function playOnly(id) { + if (id === (playing?.id ?? null)) return; + if (playing) { + clearInterval(playing.timer); + const first = cache.get(playing.id)?.[0]; + if (first) show(playing.id, first, false); + playing = null; + } + const card = id ? app.cards.get(id)?.card : null; + if (!id || !card || card.frames < 2 || still.matches) return; + const list = framesOf(id, card); + let k = 0; + playing = { id, timer: window.setInterval(() => show(id, list[(k = (k + 1) % list.length)], true), Math.max(card.delay_ms, 20)) }; +} From 0145e54e03030153c053388dcc1279fdeb1260e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:00:01 +0000 Subject: [PATCH 7/8] web: places only in grids, week labels, profile saves itself, scanner frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Archive and bin leave the bar beside a single card. - Week labels keep a day with its month and match the months' size. - Profile fields save after a pause or on leaving; the network is the name's prefix; «Restore another key» is now «Switch key»; storage offers to ask the browser to keep the data. - The scanner frame is the camera button wherever the scanner is used. Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 14 +++++++++ tests/e2e/app.test.mjs | 34 ++++++++++++++++++++ web/css/app.css | 18 +++++++++-- web/css/collection.css | 2 +- web/src/collection/dates.js | 8 +++-- web/src/collection/stage.js | 2 +- web/src/collection/view.js | 4 ++- web/src/dom.js | 1 + web/src/i18n.js | 16 ++++++---- web/src/ui/me.js | 63 +++++++++++++++++++++++++++++-------- web/src/ui/scan.js | 11 ++++--- 11 files changed, 142 insertions(+), 31 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 814b756..e3ba687 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -315,6 +315,20 @@ - Уведомления — всегда в одном месте, под верхней панелью: низ занят то вкладками, то действиями карточки, то полосой архива, а уведомление, которое переезжает вслед за ними, прыгает прямо во время чтения. +- Архив и корзина в шапке — только у сеток. Рядом с одной карточкой корзина + читается как «удалить её», а в архив карточку и так отправляет её панель. +- Подписи недель: число и месяц не разрываются («26 окт. –» / «1 нояб.»), + шрифт ближе к месячному. Клетки остаются квадратными. Редкие недели не + склеиваем: подпись «28 сент. – 18 окт.» длиннее и хуже читается, а + настоящие коллекции растут скачками вокруг встреч, не по карточке в неделю. +- Профиль сохраняется сам, как язык: после паузы в наборе или при уходе из + поля; «Сохранено» мелькает у заголовка группы. Соцсеть — приставка в том же + поле, что и имя: без имени она читается как пустое поле, а не как выбор. +- «Сменить ключ» (группа «Ключ») и «Восстановить» (резервная копия) — два + разных действия, без второго «восстановить». Пока браузер может удалить + данные, рядом кнопка попросить его этого не делать. +- Сканер (везде, где он есть): пока камера выключена, рамка сама — кнопка + «Включить камеру». Подсказки и ошибки — внизу рамки, поверх видео тоже. ## Модули diff --git a/tests/e2e/app.test.mjs b/tests/e2e/app.test.mjs index 8e27c77..089fa9d 100644 --- a/tests/e2e/app.test.mjs +++ b/tests/e2e/app.test.mjs @@ -398,6 +398,10 @@ test('the collection: tags, search, archive and the bin, all without gestures', assert.equal(await inPlace('collection'), 18, 'undone'); await act(p, 'down'); await p.waitForTimeout(2200); + // Beside one card, the places stay out of the bar: a bin there would read as «delete this». + assert.ok(!(await p.isVisible('.collection-bar .ib[aria-label="Архив"]')), 'no places in the feed'); + await p.keyboard.press('Escape'); + await p.waitForTimeout(900); assert.equal(await p.textContent('.collection-bar .ib[aria-label="Архив"] .ib-badge'), '1'); // The archive is a place like the collection: the card opens there too. @@ -1073,3 +1077,33 @@ test('an animated card holds still in the grid, with a mark, and plays when look assert.equal(await pixels(), still, 'the first frame again'); await p.context().close(); }); + +test('the profile keeps itself as it is typed, and the scanner frame turns the camera on', async () => { + const p = await env.phone('quill'); + await tab(p, 'me'); + const field = (label) => p.locator(`section.view:visible .field:has(label:text-is("${label}")) input`); + await field('Ник').fill('quill2'); + await p.locator('.saved-mark.on').waitFor(); + assert.equal(await p.textContent('.idcard b'), 'quill2', 'the card at the top follows'); + await p.selectOption('section.view:visible select.net', 'bluesky'); + await field('Соцсеть').fill('quill.bsky'); + await field('Ссылка').fill('Quill.ART/Shop'); + await field('Ссылка').blur(); // leaving a field keeps it at once + await p.waitForTimeout(300); + const profile = () => p.evaluate(async () => (await import('/src/state.js')).app.profile); + assert.deepEqual(await profile(), { nick: 'quill2', handle: { network: 'bluesky', text: 'quill.bsky' }, link: 'quill.art/Shop' }); + // An empty name: no network is kept, whatever the list shows. + await field('Соцсеть').fill(''); + await p.waitForTimeout(1000); + assert.equal((await profile()).handle, null); + assert.equal(await p.locator('section.view:visible button:text-is("Сохранить")').count(), 0, 'nothing to press'); + assert.equal(await p.locator('section.view:visible button:text-is("Сменить ключ")').count(), 1); + + // The scanner: no separate button; the frame is the way to the camera. + await tab(p, 'receive'); + assert.equal(await p.locator('section.view:visible .btn:text-is("Включить камеру")').count(), 0); + await p.click('section.view:visible .scanner-start'); + assert.match(await textMatching(p, 'section.view:visible .scanner p', /Камера недоступна/), /Камера недоступна/, 'no camera here: said in the frame'); + assert.ok(await p.isVisible('section.view:visible .scanner-start'), 'and the frame still offers to try again'); + await p.context().close(); +}); diff --git a/web/css/app.css b/web/css/app.css index d801e0f..ae7bec8 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -116,6 +116,13 @@ svg.i { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-widt .field:focus-within { box-shadow: inset 0 0 0 2px var(--cobalt-hi); } .field .prefix { font-size: 15px; color: var(--text3); margin-right: -10px; user-select: none; } .field select option { background: var(--raise); } +/* A select that is the prefix of the field's input (a network before a name). */ +.field select.net { flex: none; color: var(--text2); padding-right: 10px; border-right: 1px solid var(--line); } +/* «Saved», beside a group's heading, for a moment after each save. */ +.saved-mark { margin-left: 8px; color: var(--cobalt-hi); opacity: 0; } +.saved-mark.on { animation: saved-mark 1.8s ease-out; } +@keyframes saved-mark { 0%, 70% { opacity: 1; } 100% { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { .saved-mark.on { animation-duration: .01s; animation-delay: 1.2s; } } .group { margin-top: 24px; } .group h2 { margin: 0 0 8px; font-size: 12px; font-weight: 500; color: var(--text3); letter-spacing: .02em; } .error { color: var(--warn); font-size: 13px; margin: 8px 2px 0; min-height: 1em; } @@ -166,8 +173,15 @@ svg.i { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-widt background: #121318; box-shadow: inset 0 0 0 1px var(--line); } .scanner video { width: 100%; height: 100%; object-fit: cover; display: block; } .scanner .corners { position: absolute; inset: 18%; border-radius: 14px; box-shadow: 0 0 0 2px rgba(109, 143, 214, .9); } -.scanner p { position: absolute; inset: 0; margin: 0; display: flex; align-items: center; justify-content: center; text-align: center; - padding: 40px; color: var(--text3); font-size: 13px; } +/* Off, the frame is the button that turns the camera on; on, the words stay + under the picture, out of the way of the code. */ +.scanner-start { position: absolute; inset: 0; border: 0; background: none; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 10px; color: var(--cobalt-hi); font-size: 15px; } +.scanner-start svg { width: 36px; height: 36px; } +.scanner-start:active { background: #ffffff08; } +.scanner-start:focus-visible { outline: 2px solid var(--cobalt-hi); outline-offset: -4px; border-radius: 22px; } +.scanner p { position: absolute; left: 0; right: 0; bottom: 0; margin: 0; text-align: center; pointer-events: none; + padding: 0 24px 4.5%; color: var(--text2); font-size: 13px; text-shadow: 0 1px 3px #000; } .progress { height: 4px; border-radius: 4px; background: var(--raise2); overflow: hidden; margin: 12px auto 0; width: min(100%, 360px); } .progress i { display: block; height: 100%; background: var(--cobalt-hi); width: 0; transition: width .2s; } diff --git a/web/css/collection.css b/web/css/collection.css index 4bbc83a..73c7ad2 100644 --- a/web/css/collection.css +++ b/web/css/collection.css @@ -47,7 +47,7 @@ /* Group labels: a cell of the grid, type sized to the cell. */ .stage-label { position: absolute; left: 0; top: 0; display: flex; flex-direction: column; justify-content: flex-end; padding: 0 4px 8px; pointer-events: none; } -.stage-label b { font: 600 1em/1.1 var(--serif); color: var(--text); } +.stage-label b { font: 600 1em/1.1 var(--serif); color: var(--text); text-wrap: balance; } .stage-label span { font-size: .68em; color: var(--text3); margin-top: 3px; } .stage-label.by-author b { font-family: var(--sans); overflow-wrap: anywhere; line-height: 1.15; } diff --git a/web/src/collection/dates.js b/web/src/collection/dates.js index 76520c5..555c609 100644 --- a/web/src/collection/dates.js +++ b/web/src/collection/dates.js @@ -4,12 +4,14 @@ import { date, language } from '../i18n.js'; import { weekStart } from './layout.js'; -/** "12–18 окт." or "28 сент. – 4 окт.". @param {number} ms */ +/** "12–18 окт." or "28 сент. – 4 окт.". A day and its month never part: + * a label too long for its cell breaks at the dash, not after "4". + * @param {number} ms */ export function weekLabel(ms) { const a = new Date(weekStart(ms)), b = new Date(a); b.setDate(b.getDate() + 6); - const end = date(b.getTime(), { day: 'numeric', month: 'short' }); - return a.getMonth() === b.getMonth() ? `${a.getDate()}–${end}` : `${date(a.getTime(), { day: 'numeric', month: 'short' })} – ${end}`; + const day = (/** @type {Date} */ d) => date(d.getTime(), { day: 'numeric', month: 'short' }).replace(/\s/g, '\u00a0'); + return a.getMonth() === b.getMonth() ? `${a.getDate()}–${day(b)}` : `${day(a)} – ${day(b)}`; } /** "Октябрь", with the year when it is not this one. @param {number} ms */ diff --git a/web/src/collection/stage.js b/web/src/collection/stage.js index 85f0aec..04bf048 100644 --- a/web/src/collection/stage.js +++ b/web/src/collection/stage.js @@ -188,7 +188,7 @@ export function createStage(o) { l.classList.toggle('by-author', o.by() === 'author'); } // Type follows the cell: the label is part of the grid, not above it. - const fs = lab.w * (o.by() === 'author' ? (level === 0 ? 0.15 : 0.13) : level === 0 ? 0.19 : 0.15); + const fs = lab.w * (o.by() === 'author' ? (level === 0 ? 0.15 : 0.13) : level === 0 ? 0.19 : 0.13); Object.assign(l.style, { width: `${lab.w}px`, height: `${lab.w}px`, fontSize: `${fs}px`, opacity: String(vis), transform: `translate3d(${lab.x}px,${lab.y - scroll[level].v}px,0)`, diff --git a/web/src/collection/view.js b/web/src/collection/view.js index e19545d..25a128d 100644 --- a/web/src/collection/view.js +++ b/web/src/collection/view.js @@ -348,7 +348,9 @@ export function collectionView(o) { sub.hidden = showChip; chip.textContent = `${[filter.owner === 'all' ? '' : t(`owner.chip.${filter.owner}`), filter.query].filter(Boolean).join(' · ')} ×`; backBtn.el.hidden = (lv < 2 && filter.place === 'collection') || selecting; - archBtn.el.hidden = binBtn.el.hidden = selecting || filter.place !== 'collection'; + // The places belong to the grids: beside one card a bin reads as «delete + // this», and the archive is already the card's own action below. + archBtn.el.hidden = binBtn.el.hidden = selecting || filter.place !== 'collection' || lv >= 2; archBtn.count(inPlace('archive').length); binBtn.count(inPlace('trash').length); selBtn.hidden = (lv >= 2 && !selecting) || !stage.list.length; diff --git a/web/src/dom.js b/web/src/dom.js index cf68d41..4630746 100644 --- a/web/src/dom.js +++ b/web/src/dom.js @@ -57,6 +57,7 @@ const ICONS = { image: '', link: '', key: '', + camera: '', scan: '', brush: '', search: '', diff --git a/web/src/i18n.js b/web/src/i18n.js index 2dad268..f3875f1 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.js @@ -80,9 +80,9 @@ const ru = { 'receive.got': 'Карточка в коллекции', 'receive.dup': 'Такая карточка уже есть', 'receive.not-pips': 'Это не код Pips', 'receive.damaged': 'Код Pips, но повреждён. Попробуйте отсканировать ещё раз', 'receive.forged': 'Подпись не сходится: код повреждён или подделан. Карточка не добавлена', 'receive.recipient': 'Это код получателя — его сканирует автор при выдаче экземпляра', 'receive.paper': 'Это чей-то секретный ключ. Его нельзя показывать — тот, кто его видел, может подписывать карточки от имени владельца.', - 'me.title': 'Профиль', 'me.nick': 'Ник', 'me.handle': 'Хэндл', 'me.network': 'Сеть', 'me.link': 'Ссылка', + 'me.title': 'Профиль', 'me.nick': 'Ник', 'me.handle': 'Соцсеть', 'me.handle.ph': 'Имя там', 'me.network': 'Соцсеть', 'me.link': 'Ссылка', 'me.link.hint': 'Например, kitsu.art/shop — https:// добавится сам', - 'me.card-back': 'На обороте ваших карточек', 'me.save': 'Сохранить', 'me.saved': 'Сохранено', + 'me.card-back': 'На обороте ваших карточек', 'me.saved': 'Сохранено', 'backup.title': 'Резервная копия', 'backup.save': 'Сохранить копию', 'backup.restore': 'Восстановить', 'backup.never': 'Копии ещё нет. Ключ восстанавливается словами, а коллекция — только из копии.', 'backup.last': (/** @type {string} */ d, /** @type {number} */ n) => `Последняя: ${d}, ${n} ${plural(n, 'карточка', 'карточки', 'карточек')}`, @@ -99,7 +99,7 @@ const ru = { 'backup.err.invalid_backup': 'Копия не прошла проверку', 'backup.err.too_large': 'Файл слишком большой', 'me.key': 'Ключ', 'me.words': '24 слова', 'me.paper': 'Бумажный ключ', - 'me.switch': 'Восстановить другой ключ', + 'me.switch': 'Сменить ключ', 'me.switch.confirm': (/** @type {number} */ n) => `${n} ${plural(n, 'ваша карточка подписана', 'ваши карточки подписаны', 'ваших карточек подписаны')} нынешним ключом. После смены они останутся у вас, но выдавать их номерные экземпляры отсюда будет нельзя. Если слова этого ключа не записаны — запишите их сначала.`, 'me.switch.go': 'Сменить ключ', 'me.switch.done': 'Ключ сменён', 'me.switch.same': 'Это тот же ключ: копия добавлена', 'me.words.hide': 'Скрыть слова', 'me.paper.hide': 'Скрыть ключ', 'me.paper.text': 'Показать ключ текстом', @@ -116,6 +116,8 @@ const ru = { 'journal.toast': 'Что-то пошло не так — это записано в журнал', 'journal.toast.go': 'Журнал', 'me.storage': 'Хранилище', 'me.storage.persisted': 'Данные защищены от автоочистки браузера', 'me.storage.not-persisted': 'Браузер может очистить данные при нехватке места — делайте резервные копии', + 'me.storage.ask': 'Попросить браузер не удалять', 'me.storage.granted': 'Браузер обещал не удалять данные', + 'me.storage.refused': 'Браузер не согласился — пусть выручают копии. Установленное на экран «Домой» приложение он охраняет охотнее', 'me.storage.memory': 'Хранилище браузера недоступно: всё пропадёт, когда страница закроется', 'me.language': 'Язык', 'me.language.later': 'Язык сменится при следующем запуске', 'dev.title': 'Для разработчиков', 'dev.phrase': 'Фраза для входа', @@ -335,9 +337,9 @@ const en = { 'receive.got': 'Card added to the collection', 'receive.dup': 'You already have this card', 'receive.not-pips': 'Not a Pips code', 'receive.damaged': 'A Pips code, but damaged. Try scanning again', 'receive.forged': 'The signature does not match: the code is damaged or forged. Card not added', 'receive.recipient': 'That is a recipient code — the artist scans it to issue a copy', 'receive.paper': 'That is someone’s secret key. Anyone who sees it can sign cards as its owner.', - 'me.title': 'Profile', 'me.nick': 'Name', 'me.handle': 'Handle', 'me.network': 'Network', 'me.link': 'Link', + 'me.title': 'Profile', 'me.nick': 'Name', 'me.handle': 'Social', 'me.handle.ph': 'Name there', 'me.network': 'Social network', 'me.link': 'Link', 'me.link.hint': 'For example kitsu.art/shop — https:// is added for you', - 'me.card-back': 'On the back of your cards', 'me.save': 'Save', 'me.saved': 'Saved', + 'me.card-back': 'On the back of your cards', 'me.saved': 'Saved', 'backup.title': 'Backup', 'backup.save': 'Save a backup', 'backup.restore': 'Restore', 'backup.never': 'No backup yet. Your key comes back from the words; your collection only from a backup.', 'backup.last': (/** @type {string} */ d, /** @type {number} */ n) => `Last: ${d}, ${n} card${n === 1 ? '' : 's'}`, @@ -354,7 +356,7 @@ const en = { 'backup.err.invalid_backup': 'The backup did not pass its checks', 'backup.err.too_large': 'The file is too large', 'me.key': 'Key', 'me.words': '24 words', 'me.paper': 'Paper key', - 'me.switch': 'Restore another key', + 'me.switch': 'Switch key', 'me.switch.confirm': (/** @type {number} */ n) => `${n} of your cards ${n === 1 ? 'is' : 'are'} signed with the present key. After the switch they stay with you, but their numbered copies can no longer be issued from here. If this key's words are not written down, write them down first.`, 'me.switch.go': 'Switch key', 'me.switch.done': 'Key switched', 'me.switch.same': 'The same key: the backup was added', 'me.words.hide': 'Hide the words', 'me.paper.hide': 'Hide the key', 'me.paper.text': 'Show the key as text', @@ -371,6 +373,8 @@ const en = { 'journal.toast': 'Something went wrong — it is in the journal', 'journal.toast.go': 'Journal', 'me.storage': 'Storage', 'me.storage.persisted': 'Protected from automatic browser clean-up', 'me.storage.not-persisted': 'The browser may clear data when space runs low — keep backups', + 'me.storage.ask': 'Ask the browser to keep it', 'me.storage.granted': 'The browser will keep the data', + 'me.storage.refused': 'The browser said no — backups it is. It is more willing once the app is on the home screen', 'me.storage.memory': 'Browser storage is unavailable: everything is lost when this page closes', 'me.language': 'Language', 'me.language.later': 'The language changes on the next start', 'dev.title': 'For developers', 'dev.phrase': 'Phrase to enter', diff --git a/web/src/ui/me.js b/web/src/ui/me.js index 3cc5c6d..c560819 100644 --- a/web/src/ui/me.js +++ b/web/src/ui/me.js @@ -4,7 +4,7 @@ import { h, replace, byteLimit } from '../dom.js'; import { t, reason, language, date } from '../i18n.js'; import { app, setProfile, makeBackup, restoreBackup, lastBackup } from '../state.js'; -import { isPersisted, setMeta, memoryOnly } from '../store.js'; +import { isPersisted, persist, setMeta, memoryOnly } from '../store.js'; import { toast, ask } from './shell.js'; import { qrText } from './qr.js'; import { identicon } from '../kit/identicon.js'; @@ -26,10 +26,13 @@ export function meView() { const key = app.identity.publicKey(); const p = app.profile; const nick = byteLimit(/** @type {HTMLInputElement} */ (h('input', { value: p.nick, autocomplete: 'nickname' })), 24); - const network = /** @type {HTMLSelectElement} */ (h('select', {}, NETWORKS.map((n) => h('option', { value: n, selected: p.handle?.network === n }, n)))); - const handle = byteLimit(/** @type {HTMLInputElement} */ (h('input', { value: p.handle?.text ?? '', autocomplete: 'off' })), 48); + // The network is the handle's prefix, in the same field: with no name + // after it, it reads as an empty field, not as a choice already made. + const network = /** @type {HTMLSelectElement} */ (h('select', { class: 'net', 'aria-label': t('me.network') }, NETWORKS.map((n) => h('option', { value: n, selected: p.handle?.network === n }, n)))); + const handle = byteLimit(/** @type {HTMLInputElement} */ (h('input', { value: p.handle?.text ?? '', placeholder: t('me.handle.ph'), autocomplete: 'off', 'aria-label': t('me.handle.ph') })), 48); const link = byteLimit(/** @type {HTMLInputElement} */ (h('input', { value: p.link, placeholder: 'kitsu.art', autocomplete: 'off', inputMode: 'url' })), 64); const error = h('div', { class: 'error' }); + const saved = h('span', { class: 'saved-mark', 'aria-live': 'polite' }); const secret = h('div', { class: 'secret' }); const lang = /** @type {HTMLSelectElement} */ (h('select', { onchange: async () => { @@ -41,12 +44,18 @@ export function meView() { }, h('option', { value: 'ru', selected: language() === 'ru' }, 'Русский'), h('option', { value: 'en', selected: language() === 'en' }, 'English'))); const persisted = await isPersisted(); + // The fields keep themselves, as the language does: a pause in typing or + // leaving a field is enough. Something the card cannot carry stays in the + // field with the reason under it, and nothing is kept until it can. + let timer = 0; const save = async () => { + clearTimeout(timer); const next = { nick: nick.value.trim(), handle: handle.value.trim() ? { network: network.value, text: handle.value.trim() } : null, link: normalLink(link.value), }; + if (JSON.stringify(next) === JSON.stringify({ nick: app.profile.nick, handle: app.profile.handle ?? null, link: app.profile.link })) return; // The core is the judge of what a card may carry. try { app.core.shareSize({ ...BLANK_CARD, author: next.nick, handle: next.handle, link: next.link }); @@ -55,9 +64,19 @@ export function meView() { return; } error.textContent = ''; - await setProfile(next); - toast(t('me.saved')); + await setProfile({ ...app.profile, ...next }); + idNick.textContent = next.nick || '—'; + saved.textContent = t('me.saved'); + saved.classList.remove('on'); + void saved.offsetWidth; // the mark shows afresh for each save + saved.classList.add('on'); }; + for (const f of [nick, handle, link]) { + f.addEventListener('input', () => { clearTimeout(timer); timer = window.setTimeout(save, 700); }); + f.addEventListener('change', save); + } + network.addEventListener('change', save); + const idNick = h('b', {}, p.nick || '—'); // Each button shows its secret and, pressed again, hides it; only one // is open at a time, and leaving the tab hides both. @@ -90,22 +109,19 @@ export function meView() { }; replace(body, - h('div', { class: 'idcard' }, identicon(key.hex), h('div', {}, h('b', {}, p.nick || '—'), h('small', {}, `${t('me.key')} ${key.short}`))), - h('div', { class: 'group' }, h('h2', {}, t('me.card-back')), + h('div', { class: 'idcard' }, identicon(key.hex), h('div', {}, idNick, h('small', {}, `${t('me.key')} ${key.short}`))), + h('div', { class: 'group' }, h('h2', {}, t('me.card-back'), saved), h('div', { class: 'field' }, h('label', {}, t('me.nick')), nick), - h('div', { class: 'field' }, h('label', {}, t('me.network')), network), - h('div', { class: 'field' }, h('label', {}, t('me.handle')), handle), + h('div', { class: 'field' }, h('label', {}, t('me.handle')), network, handle), h('div', { class: 'field' }, h('label', {}, t('me.link')), h('span', { class: 'prefix', 'aria-hidden': 'true' }, 'https://'), link), - h('p', { class: 'hint' }, t('me.link.hint')), error, - h('button', { class: 'btn key wide', onclick: save }, t('me.save'))), + h('p', { class: 'hint' }, t('me.link.hint')), error), h('div', { class: 'group' }, h('h2', {}, t('me.key')), h('div', { class: 'row-btns' }, wordsBtn, paperBtn), secret, h('button', { class: 'btn wide', style: { marginTop: '8px' }, onclick: () => switchKey(null) }, t('me.switch'))), await backupGroup(switchKey), h('div', { class: 'group' }, h('h2', {}, t('me.language')), h('div', { class: 'field' }, lang)), - h('div', { class: 'group' }, h('h2', {}, t('me.storage')), - h('p', { class: 'hint' }, t(memoryOnly ? 'me.storage.memory' : persisted ? 'me.storage.persisted' : 'me.storage.not-persisted'))), + storageGroup(persisted), journalGroup(), await developerGroup()); } @@ -128,6 +144,27 @@ export function meView() { return { el, show: render }; } +/** + * Storage: whether the browser may clear it, and — while it may — the way + * to ask it not to. Some browsers ask the person, some decide by themselves. + * @param {boolean} persisted + */ +function storageGroup(persisted) { + const box = h('div', { class: 'group' }); + const draw = (/** @type {boolean} */ kept) => replace(box, h('h2', {}, t('me.storage')), + h('p', { class: 'hint lead' }, t(memoryOnly ? 'me.storage.memory' : kept ? 'me.storage.persisted' : 'me.storage.not-persisted')), + memoryOnly || kept ? '' : h('button', { + class: 'btn wide', + onclick: async () => { + const ok = await persist().catch(() => false); + if (ok) draw(true); + toast(t(ok ? 'me.storage.granted' : 'me.storage.refused')); + }, + }, t('me.storage.ask'))); + draw(persisted); + return box; +} + /** Backup: save, restore, and how stale the last copy is. The group redraws * itself after each action, so its status is never out of date. */ /** @param {(file: {bytes: Uint8Array, name: string}) => void} switchKey for another key's backup */ diff --git a/web/src/ui/scan.js b/web/src/ui/scan.js index d6eaa95..d6941a4 100644 --- a/web/src/ui/scan.js +++ b/web/src/ui/scan.js @@ -1,5 +1,7 @@ // @ts-check // A QR scanner: the camera, plus "from a picture" for when there is none. +// While the camera is off, the frame itself is the button that turns it on: +// the place where the picture will be is where one reaches for it. // Hands every text it reads to `onText`; deciding what it means is the // caller's job. Repeats of the same text are left for the caller too. @@ -11,11 +13,11 @@ import { qrReader } from '../qrscan.js'; export function scanner(onText, hint = t('receive.point')) { const video = /** @type {HTMLVideoElement} */ (h('video', { playsInline: true, muted: true, hidden: true })); const message = h('p', {}, hint); - const startBtn = h('button', { class: 'btn key', onclick: () => start() }, t('receive.start')); + const startBtn = h('button', { type: 'button', class: 'scanner-start', onclick: () => start() }, icon('camera'), h('b', {}, t('receive.start'))); const imageInput = /** @type {HTMLInputElement} */ (h('input', { type: 'file', accept: 'image/*', hidden: true, onchange: () => fromImage() })); const el = h('div', {}, - h('div', { class: 'scanner' }, video, h('div', { class: 'corners' }), message), - h('div', { class: 'group row-btns' }, startBtn, + h('div', { class: 'scanner' }, video, h('div', { class: 'corners' }), startBtn, message), + h('div', { class: 'group row-btns' }, h('button', { class: 'btn', onclick: () => imageInput.click() }, icon('image'), t('receive.image'))), imageInput); @@ -35,6 +37,7 @@ export function scanner(onText, hint = t('receive.point')) { } async function start() { + if (stream) return; message.textContent = t('scan.loading'); const r = await reader(); if (!r) return; @@ -46,7 +49,7 @@ export function scanner(onText, hint = t('receive.point')) { } video.srcObject = stream; video.hidden = false; - message.textContent = ''; + message.textContent = hint; startBtn.hidden = true; await video.play(); // The browser's detector is fast; ours is given more time per frame. From e006c65834f543f3bdd5550331eb23cda14a216c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 12:57:06 +0000 Subject: [PATCH 8/8] web: feed follows the hand; audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The feed after a lift: a slow hand lands on the nearest card, a swipe goes one card, a flick carries on further the faster it is. A finger's speed is read from its last 100 ms only, and is none if it stopped before lifting. - The pen and shape kinds close on Esc or a tap elsewhere, without leaving the editor or pressing what lies under them. - Storage writes resolve once the transaction commits. - A backup file larger than the core accepts is not read into memory. - A picture the scanner cannot decode says so instead of failing silently. - Moves to the bin and purges never leave the collection waiting after a failed write; the archive flight is instant with reduced motion; the «Saved» mark shows with reduced motion too. - The collection bar is solid under its subtitle. Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 9 ++++++ tests/e2e/app.test.mjs | 57 ++++++++++++++++++++++++++++++++++ tests/unit/collection.test.mjs | 19 +++++++++++- web/css/app.css | 3 +- web/css/collection.css | 2 +- web/src/collection/layout.js | 19 ++++++++++++ web/src/collection/moves.js | 24 +++++++++----- web/src/collection/stage.js | 9 ++---- web/src/editor/tools.js | 29 +++++++++++++++++ web/src/editor/view.js | 2 +- web/src/kit/gestures.js | 17 ++++++++-- web/src/state.js | 14 +++++++++ web/src/store.js | 22 +++++++++++-- web/src/ui/me.js | 6 ++-- web/src/ui/onboarding.js | 12 +++++-- web/src/ui/scan.js | 3 +- 16 files changed, 218 insertions(+), 29 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index e3ba687..10f2cbc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -327,6 +327,15 @@ - «Сменить ключ» (группа «Ключ») и «Восстановить» (резервная копия) — два разных действия, без второго «восстановить». Пока браузер может удалить данные, рядом кнопка попросить его этого не делать. +- Лента после пальца: медленная рука — ближайшая карточка, без броска; + свайп — одна карточка в его сторону; резкий бросок — чем быстрее, тем + дальше (до 10). Скорость берётся только из последних 100 мс движения, и + её нет, если палец перед отрывом замер: аккуратное движение не бросает. +- Ряд видов у карандаша и фигур — маленькое меню: Esc и касание мимо + закрывают только его, и это касание ничего под ним не нажимает. +- Запись в хранилище считается сделанной, когда транзакция завершилась, а + не когда принят запрос: иначе переполненный диск выдаёт ложное «сохранено». +- Резервную копию больше лимита ядра (64 МБ) не читаем в память вовсе. - Сканер (везде, где он есть): пока камера выключена, рамка сама — кнопка «Включить камеру». Подсказки и ошибки — внизу рамки, поверх видео тоже. diff --git a/tests/e2e/app.test.mjs b/tests/e2e/app.test.mjs index 089fa9d..533148a 100644 --- a/tests/e2e/app.test.mjs +++ b/tests/e2e/app.test.mjs @@ -1107,3 +1107,60 @@ test('the profile keeps itself as it is typed, and the scanner frame turns the c assert.ok(await p.isVisible('section.view:visible .scanner-start'), 'and the frame still offers to try again'); await p.context().close(); }); + +test('the feed under a finger: held still before lifting it stays, a flick carries on', async () => { + const p = await env.phone('flickfox'); + await tab(p, 'me'); + await samples(p); + await tab(p, 'collection'); + await p.waitForTimeout(800); + await p.keyboard.press('+'); + await p.waitForTimeout(900); + const centre = () => p.$eval('.stage-host .on-stage.centre', (e) => [...e.parentElement.children].indexOf(e)); + /** A touch across the feed: `dx` in `ms`, then `still` ms without moving before the lift. */ + const swipe = (dx, ms, still = 0) => p.evaluate(async ([dx, ms, still]) => { + const host = document.querySelector('.stage-host'), x0 = 195 - dx / 2, y = 400; + const fire = (type, x) => host.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 9, pointerType: 'touch', isPrimary: true, clientX: x, clientY: y })); + fire('pointerdown', x0); + for (let i = 1; i <= 10; i++) { await new Promise((r) => setTimeout(r, ms / 10)); fire('pointermove', x0 + (dx * i) / 10); } + await new Promise((r) => setTimeout(r, still)); + fire('pointerup', x0 + dx); + }, [dx, ms, still]); + const settle = () => p.waitForTimeout(1500); + + const c0 = await centre(); + // Fast across a third of a card, then held: no throw, back where it was. + await swipe(-60, 60, 250); + await settle(); + assert.equal(await centre(), c0, 'a finger that stopped throws nothing'); + // A short quick swipe: one card on. + await swipe(-60, 80); + await settle(); + assert.equal(await centre(), c0 + 1, 'a swipe: the next card'); + // A hard flick: several cards. + await swipe(-260, 70); + await settle(); + assert.ok((await centre()) >= c0 + 4, `a flick carries on (now ${await centre()})`); + await p.context().close(); +}); + +test('a row of kinds in the editor is a small menu: Esc or a tap elsewhere puts only it away', async () => { + const p = await env.phone('kindfox'); + await tab(p, 'draw'); + await p.locator('.ed-art').waitFor(); + const row = 'section.view:visible .ed-shapes:not([hidden])'; + await p.click('.ed-tool-pen'); // the pen is already chosen: a second tap opens its kinds + await p.locator(row).waitFor(); + await p.keyboard.press('Escape'); + assert.equal(await p.locator(row).count(), 0, 'the menu went'); + assert.ok(await p.isVisible('.ed-tools'), 'the editor stayed'); + // A tap on a colour under the menu only closes the menu. + await p.click('.ed-tool-pen'); + const chosen = () => p.$eval('.ed-swatch[aria-pressed="true"]', (b) => b.getAttribute('aria-label')).catch(() => null); + const before = await chosen(); + const sw = await p.locator('.ed-swatch').nth(10).boundingBox(); + await p.mouse.click(sw.x + sw.width / 2, sw.y + sw.height / 2); + assert.equal(await p.locator(row).count(), 0, 'the menu went'); + assert.equal(await chosen(), before, 'and nothing under it was pressed'); + await p.context().close(); +}); diff --git a/tests/unit/collection.test.mjs b/tests/unit/collection.test.mjs index ee195ae..44f36ce 100644 --- a/tests/unit/collection.test.mjs +++ b/tests/unit/collection.test.mjs @@ -3,7 +3,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { normalTag, matches, select, toggleTag, tagCounts } from '../../web/src/collection/query.js'; -import { grid, feedRect, runs, groupKey, weekStart, columns } from '../../web/src/collection/layout.js'; +import { grid, feedRect, feedTarget, runs, groupKey, weekStart, columns } from '../../web/src/collection/layout.js'; import { Spring, flight, rubber } from '../../web/src/kit/motion.js'; const DAY = 86_400_000; @@ -77,3 +77,20 @@ test('springs settle; a flight ends where it was sent', () => { assert.deepEqual(f.at(600), { x: 50, y: 300, w: 56, t: 1 }); assert.equal(rubber(110, 0, 100), 103.5); }); + +test('the feed after a lift: a slow hand stays in control, a swipe goes one card, a flick carries on', () => { + // Slow: the nearest card, whichever way the finger was going. + assert.equal(feedTarget(3.4, 0.5, 3), 3); + assert.equal(feedTarget(3.6, -0.5, 3), 4); + // A swipe: one card on from the start, however short the drag… + assert.equal(feedTarget(3.1, 3, 3), 4); + assert.equal(feedTarget(2.9, -3, 3), 2); + // …but not past the card it has nearly reached. + assert.equal(feedTarget(3.95, 3, 3), 4); + assert.equal(feedTarget(5.5, 3, 3), 6); + // A flick: the faster, the further, within a limit. + const far = (v) => feedTarget(3, v, 3) - 3; + assert.ok(far(8) >= 2 && far(8) < far(20), 'faster goes further'); + assert.ok(far(1000) <= 10, 'never more than ten cards'); + assert.equal(far(-20), -far(20), 'the same both ways'); +}); diff --git a/web/css/app.css b/web/css/app.css index ae7bec8..4b6a0aa 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -122,7 +122,8 @@ svg.i { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-widt .saved-mark { margin-left: 8px; color: var(--cobalt-hi); opacity: 0; } .saved-mark.on { animation: saved-mark 1.8s ease-out; } @keyframes saved-mark { 0%, 70% { opacity: 1; } 100% { opacity: 0; } } -@media (prefers-reduced-motion: reduce) { .saved-mark.on { animation-duration: .01s; animation-delay: 1.2s; } } +/* Without motion the mark simply stays, until the group is drawn again. */ +@media (prefers-reduced-motion: reduce) { .saved-mark.on { opacity: 1; } } .group { margin-top: 24px; } .group h2 { margin: 0 0 8px; font-size: 12px; font-weight: 500; color: var(--text3); letter-spacing: .02em; } .error { color: var(--warn); font-size: 13px; margin: 8px 2px 0; min-height: 1em; } diff --git a/web/css/collection.css b/web/css/collection.css index 73c7ad2..f9f94fc 100644 --- a/web/css/collection.css +++ b/web/css/collection.css @@ -4,7 +4,7 @@ .view.collection-view { inset: 0; touch-action: none; -webkit-user-select: none; user-select: none; } .bar.collection-bar { position: absolute; z-index: 50; left: 0; right: 0; top: 0; gap: 8px; padding-left: max(20px, calc((100% - 1100px) / 2 + 16px)); padding-right: max(16px, calc((100% - 1100px) / 2 + 16px)); - background: linear-gradient(var(--void) 72%, #1a1b2100); pointer-events: none; } + background: linear-gradient(var(--void) calc(100% - 8px), #1a1b2100); pointer-events: none; } /* solid under the line below the title too */ .collection-bar > * { pointer-events: auto; } .collection-bar .sub { display: flex; gap: 8px; align-items: center; min-height: 18px; white-space: nowrap; overflow: hidden; } .level-switch { border: 0; background: none; padding: 0; font: inherit; color: inherit; text-align: left; diff --git a/web/src/collection/layout.js b/web/src/collection/layout.js index d7b5f55..4b51ecb 100644 --- a/web/src/collection/layout.js +++ b/web/src/collection/layout.js @@ -85,6 +85,25 @@ export function grid(level, groups, f) { /** The feed card's width. @param {Frame} f */ export const feedWidth = (f) => Math.min(f.W * 0.68, (f.H - f.top - f.bottom) * 0.6, 520); +/** + * Where the feed comes to rest after the finger lifts, from where it is + * (in cards) and the finger's speed (cards a second, forward positive). + * A slow hand is in control: the nearest card, nothing more. A swipe goes + * on to the next card its way: past the one it started from, however + * short, but not past one it has nearly reached. A flick carries on, the + * faster the further, like a thrown card. + * @param {number} at @param {number} speed @param {number} from the card the touch began on + */ +export function feedTarget(at, speed, from) { + const a = Math.abs(speed), dir = Math.sign(speed); + if (a < FEED_SLOW) return Math.round(at); + const next = dir > 0 ? Math.max(Math.ceil(at - 0.15), Math.round(from) + 1) : Math.min(Math.floor(at + 0.15), Math.round(from) - 1); + if (a < FEED_FLICK) return next; + return next + dir * Math.round(Math.min(FEED_MAX - 1, (a - FEED_FLICK) * 0.3)); +} +/** Cards a second: below, the nearest card; to FEED_FLICK, one card; past it, a throw. */ +const FEED_SLOW = 1, FEED_FLICK = 5, FEED_MAX = 10; + /** The single card's width. @param {Frame} f */ export const cardWidth = (f) => Math.min(f.W * 0.84, (f.H - f.top - f.bottom - 24) / 1.29, 560); diff --git a/web/src/collection/moves.js b/web/src/collection/moves.js index f6c1026..d84b765 100644 --- a/web/src/collection/moves.js +++ b/web/src/collection/moves.js @@ -11,7 +11,7 @@ import { t } from '../i18n.js'; import { app, move, purge } from '../state.js'; import { artCanvas, FRAME_SHADES } from '../ui/card.js'; import { toast, ask } from '../ui/shell.js'; -import { clamp, flight } from '../kit/motion.js'; +import { clamp, flight, REDUCED } from '../kit/motion.js'; /** @typedef {import('./query.js').Place} Place */ /** @typedef {import('./stage.js').Item} Item */ @@ -82,8 +82,9 @@ export function createMoves(o) { o.beforeFlight(); drawStrip(); showStrip(1, true); - const now = performance.now(), start = now + (pulled ? 0 : 160); - const dur = 620, gap = list.length <= 6 ? 190 : Math.max(60, 1100 / list.length); + // With less motion asked for, the cards are simply there: no lift, no arc, no queue. + const now = performance.now(), start = now + (pulled || REDUCED ? 0 : 160); + const dur = REDUCED ? 1 : 620, gap = REDUCED ? 0 : list.length <= 6 ? 190 : Math.max(60, 1100 / list.length); list.forEach((c, k) => { const r0 = c.el.getBoundingClientRect(), t0 = start + k * gap; const path = flight({ x: r0.left, y: r0.top, w: r0.width }, () => strip.slot(0), { start: t0, dur }); @@ -150,9 +151,13 @@ export function createMoves(o) { /** Moves cards without a flight: they fade out here, or in. @param {string[]} ids @param {Place} to */ async function relocate(ids, to) { o.busy.n++; - for (const id of ids) await move(id, to); - o.busy.n--; - o.sync(); + // A failed write must not leave the screen waiting for a move that ended. + try { + for (const id of ids) await move(id, to); + } finally { + o.busy.n--; + o.sync(); + } } return { @@ -174,8 +179,11 @@ export function createMoves(o) { async purgeAll(ids) { if (!(await ask(t('confirm.purge-n', ids.length), { yes: t('ask.delete'), danger: true }))) return; o.busy.n++; - for (const id of ids) await purge(id); - o.busy.n--; + try { + for (const id of ids) await purge(id); + } finally { + o.busy.n--; + } o.selection.stop(); o.sync(); toast(t('moved.purged', ids.length)); diff --git a/web/src/collection/stage.js b/web/src/collection/stage.js index 04bf048..c71b933 100644 --- a/web/src/collection/stage.js +++ b/web/src/collection/stage.js @@ -14,7 +14,7 @@ import { h } from '../dom.js'; import { Spring, SPRINGS, loop, mix, clamp, ease, rubber } from '../kit/motion.js'; import { gestures } from '../kit/gestures.js'; -import { grid, feedRect, feedWidth, runs, groupKey } from './layout.js'; +import { grid, feedRect, feedWidth, feedTarget, runs, groupKey } from './layout.js'; /** * One card on the stage. @@ -298,11 +298,8 @@ export function createStage(o) { return { move: (q) => { feed.set(rubber(f0 - q.dx / step, 0, n, 0.3)); kick(); }, end: (_q, v) => { - // A flick carries on as a thrown card would: the faster, the - // further (up to a few cards), then the spring settles it. - const throwBy = clamp((-v.x / step) * 0.75, -5, 5); - feed.to = clamp(Math.round(feed.v + throwBy), 0, n); - feed.vel = clamp(-v.x / step, -12, 12); // it keeps the finger's speed + feed.to = clamp(feedTarget(feed.v, -v.x / step, f0), 0, n); + feed.vel = clamp(-v.x / step, -20, 20); // it keeps the finger's speed kick(); }, }; diff --git a/web/src/editor/tools.js b/web/src/editor/tools.js index d71d9ba..3c52c58 100644 --- a/web/src/editor/tools.js +++ b/web/src/editor/tools.js @@ -63,8 +63,37 @@ export function toolBar(o) { o.tool(id); } + // A row of kinds is a small menu: a tap anywhere else puts it away, as + // Esc does, and nothing under it is pressed by the same tap. + const rows = [shapes.el, pens.el]; + let swallow = false; + document.addEventListener('pointerdown', (e) => { + swallow = false; + const at = /** @type {Node} */ (e.target); + for (const r of rows) { + if (r.hidden || r.contains(at) || tools.some((b) => b.contains(at))) continue; + r.hidden = true; + swallow = true; + e.preventDefault(); + e.stopPropagation(); + } + }, true); + // The click that follows the same tap. + document.addEventListener('click', (e) => { + if (!swallow) return; + swallow = false; + e.preventDefault(); + e.stopPropagation(); + }, true); + return { el, + /** Puts away an open row of kinds. @returns {boolean} whether one was open */ + closeKinds() { + const open = rows.some((r) => !r.hidden); + for (const r of rows) r.hidden = true; + return open; + }, /** @param {{tool: Tool, shape: Shape, pen: PenMode, mirror: boolean, precise: boolean}} s */ set(s) { current = s.tool; diff --git a/web/src/editor/view.js b/web/src/editor/view.js index fa0da8b..40ff582 100644 --- a/web/src/editor/view.js +++ b/web/src/editor/view.js @@ -465,7 +465,7 @@ export function editorView() { else if (e.key === '-') canvas.zoomBy(0.8, centre()); else if (e.key === '0') canvas.fit(); else if (/^[1-9]$/.test(e.key) && Number(e.key) <= model.doc.colors.length) { state.color = Number(e.key) - 1; paint(); } - else if (e.key === 'Escape') { if (state.playing) stop(); else if (state.precise) { state.precise = false; paint(); } else go('collection'); } + else if (e.key === 'Escape') { if (tools.closeKinds()) { /* the menu goes, the editor stays */ } else if (state.playing) stop(); else if (state.precise) { state.precise = false; paint(); } else go('collection'); } else return; e.preventDefault(); follow(); // a zoom moves the canvas under a still mouse diff --git a/web/src/kit/gestures.js b/web/src/kit/gestures.js index 95432f6..aa48ed5 100644 --- a/web/src/kit/gestures.js +++ b/web/src/kit/gestures.js @@ -16,6 +16,10 @@ const SLOP = 8; const HOLD_MS = 420; +/** A lift's speed is read from this last stretch of the move… */ +const RECENT_MS = 100; +/** …and is none if the finger has been still this long. */ +const STILL_MS = 60; /** @typedef {{x: number, y: number, x0: number, y0: number, dx: number, dy: number, id: number, target: Element|null}} Point */ /** @typedef {{move: (p: Point) => void, end: (p: Point, v: {x: number, y: number}) => void}} DragHandler */ @@ -45,9 +49,16 @@ export function gestures(el, o) { const [a, b] = [...pts.values()]; return { d0: /** @type {number} */ (g?.d0), d: Math.hypot(a.x - b.x, a.y - b.y), mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } }; }; + /** + * The finger's speed as it lifts: from its last tenth of a second only. + * A finger that stopped before lifting has no speed, however fast it + * moved before — otherwise a careful drag, held still and let go, would + * still be thrown. + */ const velocity = () => { - const h = g?.hist ?? []; - if (h.length < 2) return { x: 0, y: 0 }; + const now = performance.now(); + const h = (g?.hist ?? []).filter((s) => now - s.t <= RECENT_MS); + if (h.length < 2 || now - h[h.length - 1].t > STILL_MS) return { x: 0, y: 0 }; const a = h[0], b = h[h.length - 1], dt = Math.max(1, b.t - a.t) / 1000; return { x: (b.x - a.x) / dt, y: (b.y - a.y) / dt }; }; @@ -99,7 +110,7 @@ export function gestures(el, o) { } if (e.pointerId !== g.id) return; // only the finger that owns the touch counts g.hist.push({ x: p.x, y: p.y, t: performance.now() }); - if (g.hist.length > 6) g.hist.shift(); + if (g.hist.length > 12) g.hist.shift(); const pt = point(g.id); if (g.kind === 'pending') { if (Math.hypot(pt.dx, pt.dy) <= SLOP) return; diff --git a/web/src/state.js b/web/src/state.js index a2c17a3..1ce253b 100644 --- a/web/src/state.js +++ b/web/src/state.js @@ -283,6 +283,20 @@ export const lastBackup = async () => (await store.getMeta('lastBackup')) ?? nul */ export const restoreBackup = (bytes) => applyBackup(app.identity.openBackup(bytes)); // throws unless every card and certificate checks out +/** The largest backup the core opens (pips-store's MAX_FILE). */ +export const BACKUP_MAX = 64 * 1024 * 1024; + +/** + * A chosen backup file's bytes. Anything larger than any backup can be is + * refused before it is read: reading a stray video whole would take the + * page down before the core could say no. + * @param {Blob} f @returns {Promise} + */ +export async function backupBytes(f) { + if (f.size > BACKUP_MAX) throw Object.assign(new Error('file too large for a backup'), { code: 'too_large' }); + return new Uint8Array(await f.arrayBuffer()); +} + /** * Adds an opened backup's cards to this device. Nothing here is * overwritten: a card already on the device keeps its place, its note and diff --git a/web/src/store.js b/web/src/store.js index 25ab9d4..06b2800 100644 --- a/web/src/store.js +++ b/web/src/store.js @@ -63,19 +63,35 @@ function tx(store, mode) { return db.transaction(store, mode).objectStore(store); } +/** + * A write, done once it is on disk: a request succeeds before its + * transaction commits, and a commit can still fail (the disk is full), so + * waiting for the request alone would report a save that never happened. + * @param {string} store @param {(s: IDBObjectStore) => IDBRequest} op + */ +function write(store, op) { + return new Promise((resolve, reject) => { + const t = db.transaction(store, 'readwrite'); + op(t.objectStore(store)); + t.oncomplete = () => resolve(undefined); + t.onerror = () => reject(t.error); + t.onabort = () => reject(t.error ?? new DOMException('transaction aborted', 'AbortError')); + }); +} + /** @param {string} key */ export const getMeta = async (key) => (memoryOnly ? mem.meta.get(key) : done(tx('meta', 'readonly').get(key))); /** @param {string} key @param {any} value */ -export const setMeta = async (key, value) => (memoryOnly ? void mem.meta.set(key, value) : done(tx('meta', 'readwrite').put(value, key))); +export const setMeta = async (key, value) => (memoryOnly ? void mem.meta.set(key, value) : write('meta', (m) => m.put(value, key))); /** @returns {Promise} */ export const allCards = async () => (memoryOnly ? [...mem.cards.values()] : done(tx('cards', 'readonly').getAll())); /** @param {string} digest @returns {Promise} */ export const getCard = async (digest) => (memoryOnly ? mem.cards.get(digest) : done(tx('cards', 'readonly').get(digest))); /** @param {CardRecord} rec */ -export const putCard = async (rec) => (memoryOnly ? void mem.cards.set(rec.digest, rec) : done(tx('cards', 'readwrite').put(rec))); +export const putCard = async (rec) => (memoryOnly ? void mem.cards.set(rec.digest, rec) : write('cards', (c) => c.put(rec))); /** @param {string} digest */ -export const deleteCard = async (digest) => (memoryOnly ? void mem.cards.delete(digest) : done(tx('cards', 'readwrite').delete(digest))); +export const deleteCard = async (digest) => (memoryOnly ? void mem.cards.delete(digest) : write('cards', (c) => c.delete(digest))); /** Asks the browser not to evict our data under storage pressure. */ export async function persist() { diff --git a/web/src/ui/me.js b/web/src/ui/me.js index c560819..51c3541 100644 --- a/web/src/ui/me.js +++ b/web/src/ui/me.js @@ -3,7 +3,7 @@ import { h, replace, byteLimit } from '../dom.js'; import { t, reason, language, date } from '../i18n.js'; -import { app, setProfile, makeBackup, restoreBackup, lastBackup } from '../state.js'; +import { app, setProfile, makeBackup, restoreBackup, lastBackup, backupBytes } from '../state.js'; import { isPersisted, persist, setMeta, memoryOnly } from '../store.js'; import { toast, ask } from './shell.js'; import { qrText } from './qr.js'; @@ -175,8 +175,10 @@ async function backupGroup(switchKey) { const f = input.files?.[0]; input.value = ''; if (!f) return; - const bytes = new Uint8Array(await f.arrayBuffer()); + /** @type {Uint8Array} */ + let bytes = new Uint8Array(); try { + bytes = await backupBytes(f); const r = await restoreBackup(bytes); toast(t('backup.restored', r.added, r.known)); } catch (e) { diff --git a/web/src/ui/onboarding.js b/web/src/ui/onboarding.js index 6dc05c4..65f7ed9 100644 --- a/web/src/ui/onboarding.js +++ b/web/src/ui/onboarding.js @@ -10,7 +10,7 @@ import { h, replace, byteLimit } from '../dom.js'; import { t, reason } from '../i18n.js'; -import { app, setIdentity, setProfile, applyBackup } from '../state.js'; +import { app, setIdentity, setProfile, applyBackup, backupBytes } from '../state.js'; import { segmented } from '../kit/segmented.js'; import { identicon } from '../kit/identicon.js'; import { iconButton } from '../kit/icon-button.js'; @@ -99,7 +99,15 @@ export function onboarding(root, done, opts = {}) { async function pick() { const f = fileInput.files?.[0]; fileInput.value = ''; - if (f) take(new Uint8Array(await f.arrayBuffer()), f.name); + if (!f) return; + try { + take(await backupBytes(f), f.name); + } catch (e) { + file = null; + fileError = reason('backup.err', e, /** @type {any} */ (e)?.message); + paintFile(); + paint(); + } } /** @param {Uint8Array} bytes @param {string} name */ function take(bytes, name) { diff --git a/web/src/ui/scan.js b/web/src/ui/scan.js index d6941a4..6052038 100644 --- a/web/src/ui/scan.js +++ b/web/src/ui/scan.js @@ -78,7 +78,8 @@ export function scanner(onText, hint = t('receive.point')) { if (!f) return; const r = await reader(); if (!r) return; - const texts = await r.read(await createImageBitmap(f)).catch(() => /** @type {string[]} */ ([])); + // A file that is not a picture, or one too big to decode, simply holds no code. + const texts = await createImageBitmap(f).then((b) => r.read(b)).catch(() => /** @type {string[]} */ ([])); if (!texts.length) return say(t('scan.none')); for (const text of texts) onText(text); }