Why your INP can fall after one more click
By Aditya Reddy
Say you have a page with 49 interactions. The slowest took 480 ms, followed by 320 ms and 240 ms. Everything else was faster. Your INP is 480 ms.
Now click something fast. You have changed no code, and that slow interaction still takes 480 ms. The score for the visit drops to 320 ms anyway.
That's the part of INP I wanted to explain in my Event Timing specification PR. The PR is still open. A single number on a dashboard hides quite a bit of bookkeeping.
What happened at click 50?
INP skips one longest interaction for every complete group of 50 interactions. At 49, it picks the longest. At 50, it picks the second longest. The INP guide explains the reason for excluding these outliers on pages with lots of interactions.
For our example, assuming all the extra interactions are below 240 ms:
| Interactions so far | Longest values skipped | INP candidate |
| --- | --- | --- |
| 49 | 0 | 480 ms |
| 50 | 1 | 320 ms |
| 99 | 1 | 320 ms |
| 100 | 2 | 240 ms |
So you can cross two of these boundaries and go from 480 to 240 ms without fixing the 480 ms interaction. Nice dashboard, same slow click.
The selection code in web-vitals amounts to this, with the latencies sorted from longest to shortest:
const skip = Math.floor(interactionCount / 50);
const index = Math.min(skip, descendingLatencies.length - 1);
const candidate = descendingLatencies[index];
You need at least one candidate for this fragment. It is only the selection step, not a complete collector. web-vitals retains up to ten of the longest interactions, so the list can be much shorter than the interaction count.
Count interactions, not event entries
There is another place to get the numbers wrong before you even reach that selection.
A click can leave several PerformanceEventTiming entries. Take these made-up values from one visit:
| Interaction ID | Event | Duration |
| --- | --- | --- |
| 7 | pointerdown | 320 ms |
| 7 | pointerup | 296 ms |
| 7 | click | 280 ms |
| 14 | click | 480 ms |
| 21 | keydown | 240 ms |
The first three rows belong to one interaction. Its latency is 320 ms, the longest duration in that group. Adding them gives 896 ms, but those durations overlap.
Five rows here give us three interactions, with latencies of 320, 480 and 240 ms. Group by the nonzero interactionId. Leave entries with ID zero out of this calculation. The Event Timing spec describes how those IDs group related events.
Your observer may have missed some of it
For entries of type event, the default duration threshold is 104 ms. You can register an observer with durationThreshold: 16 to receive shorter events going forward. That does not bring back all the fast events from before registration.
Even with buffered delivery, you only get the earlier entries that were kept under the default threshold. This is covered in the spec's measurement section.
That's why the total interaction count and your observed list length need to stay separate. A fast interaction can increase the count without appearing in that list. Lowering the threshold late won't recover its duration.
I would use web-vitals for production collection. There is first-input handling, page visibility and back/forward cache restoration to account for too. Code running in one window also cannot see every interaction in an embedded frame.
When debugging the score, keep the selected interaction's ID and latency along with the total count. If the score drops, check which interaction it picked. And if you are fixing that 480 ms click, compare the click itself before and after the change under similar conditions.
The proposed spec section puts the grouping, selection, observer threshold and retained-list limit together. The fast click at interaction 50 is a useful example of why those details matter.
Правый клик с памятью: почему я храню счётчик в id пункта меню Chrome
Мои хобби-проекты - небольшие расширения для Хрома и сайты-микросервисы. Одно из расширений, PDF to PNG, внезапно забралось на первое место в поиске Chrome Web Store по запросу “pdf to png” (ну, почти всегда), и им пользуются аж под 500 человек - это потрясающе!
Раз PDF в картинки получилось, немедленно загорелось сделать и наоборот - картинки -> PDF, только ещё круче: чтоб работало через правый клик по картинке на любой странице, чтоб была лёгкая(!) возможность настроек - и чтоб всё прям локально в браузере и картинки при этом никуда не загружались!
Все замечательные настройки поместил в сайд-панель, вот только обычный пользователь может же никогда не догадаться её открыть, а таки хотелось бы дать о ней знать, но при этом не дёргать тех, кому достаточно меню правой кнопки мыши - что ж, будем показывать её иногда (ну или пока пользователь явно не кликнет куда-то в панель).
Эта хотелка упёрлась в правило Хрома: открыть панель можно только в ответ на клик, а в обработчике клика по пункту меню это значит - до первого await. А чтобы показывать иногда, на каждом клике расширение должно помнить, сколько раз уже её показало, - а прочитать это из хранилища и есть await.
Решение - хранить счётчик прямо в id пункта контекстного меню! :)
И вот - Save Image as PDF. Правый клик - и картинка уже PDF.
Правый клик по картинке → Save image as PDF
Await страницы и await воркера по-разному “сжигают” жест
Правый клик случается на чужой странице, где от расширения ничего не открыто: ни панели, ни своих страниц. Соответственно, клики по пункту меню слушает сервис-воркер.
Наивный подход - прочитать в обработчике клика, сколько раз панель уже показывали, и по этому решить, открывать ли её:
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
const { shown = 0 } = await chrome.storage.local.get('shown');
if (shown < 3) {
chrome.sidePanel.open({ tabId: tab.id });
}
});
sourcecraft.dev
Панель, конечно же, не откроется ни разу, а в консоли воркера на каждый клик будет ошибка sidePanel.open() may only be called in response to a user gesture.
Некоторые API Хрома, например chrome.sidePanel.open() и chrome.permissions.request(), работают, только если их вызвали в ответ на действие человека: клик, нажатие клавиши. Клик по пункту меню таким жестом считается, вот только живёт этот жест на странице и в воркере очень по-разному.
На обычной странице работает transient activation: после клика жест “живёт” 5 секунд. Так прямо и записано в коде Chromium, kActivationLifespan = base::Seconds(5), с пояснением “long enough to allow network round trips even in a very slow connection”. Проверить легко - вставьте в консоль DevTools на любой странице и кликните по ней:
document.onclick = () => [4000, 6000].forEach((ms) =>
setTimeout(() => console.log(ms, navigator.userActivation.isActive), ms));
// 4000 true
// 6000 false
sourcecraft.dev
А в воркере никакого таймера нет: жест держится, только пока Хром передаёт событие клика обработчику. async-обработчик на первом же await возвращает управление Хрому - и жеста больше нет. Даже await Promise.resolve(), который не ждёт вообще ничего, его сжигает. Сам await chrome.sidePanel.open() при этом можно - главное, чтобы перед ним ничего не ждали. В команде Chrome об этой разнице знают: в WebExtensions CG предлагают дать расширениям жест по времени, как на страницах, но пока это только предложение.
Почти любое полезное действие требует await: прочитать настройки, скачать картинку, собрать PDF. К тому же воркер мог только что проснуться от этого самого клика: в MV3 его выгружают примерно после 30 секунд простоя - так себе детерминизм. После такого холодного старта в памяти пусто, так что и глобальная переменная не спасёт. Как же тогда узнать в момент клика, сколько раз панель уже показывали?
Колбэк вместо await
На chromium-extensions это можно обходить старым добрым колбэком, ибо вот именно для одного колбэка Хром оставляет “жест”, но только для одного, и только для колбэка (никаких await!). Колбэк внутри колбэка жеста уже не получит.
chrome.contextMenus.onClicked.addListener((info, tab) => {
chrome.storage.local.get('shown', ({ shown = 0 }) => {
if (shown < 3) chrome.sidePanel.open({ tabId: tab.id });
// так сработает если колбэк именно один - без вложенности
});
});
sourcecraft.dev
Однако получилось придумать другой трюк и остаться в мире await!
Счётчик в id пункта меню
Я придумал такое правило, чтобы насильно наверняка познакомить пользователя с сайд-панелью, но не мозолить глаза постоянно: открывать панель на первых трёх сохранениях, потом на каждом десятом, пока пользователь не ткнёт хоть что-то в панели или не откроет её сам (ну то есть - панель точно была замечена). Для этого уже в момент клика нужно знать, какое это по счёту сохранение и какой режим выбран в настройках.
Есть одно своё значение, которое приходит в обработчик клика по меню синхронно, - это, собственно, info.menuItemId, id пункта, по которому кликнули. Это может быть неочевидным, но вообще-то ничего не мешает пересоздавать пункты меню хоть двадцать раз в день. Вот туда состояние и легло:
// id = 'save|<step>|<mode>'
// step 0..12 пользователь ещё не пользовался панелью; считаем сохранения через меню
// known пользователь открыл панель сам или что-то в ней сделал; больше не меняется
// mode download | panel | collection
export const NUDGE_SAVES = 3;
export const LAST_STEP = 12;
export const KNOWN = 'known';
const ID_PATTERN = /^save|(known|0|[1-9]\d?)|(download|panel|collection)$/;
export function parseMenuId(id: unknown): MenuState | null {
…
const match = ID_PATTERN.exec(id);
…
return { step, mode: match[2] as MenuMode };
}
export function shouldOpenPanel(state: MenuState): boolean {
if (state.mode === 'panel') return true;
if (state.step === KNOWN) return false;
return state.step < NUDGE_SAVES || state.step === LAST_STEP;
}
export function afterMenuSave(state: MenuState): MenuState {
const { step } = state;
if (step === KNOWN) return state;
return { ...state, step: step === LAST_STEP ? NUDGE_SAVES : step + 1 };
}
sourcecraft.dev
Пункт меню у расширения один, с одним и тем же заголовком “Save image as PDF”, меняется только id: save|0|download, save|12|collection, save|known|panel и т.п. Обработчик разбирает id и, если надо, открывает панель первым же вызовом, ничего не дожидаясь:
export function handleMenuClick(info, tab, deps): Promise<void> {
const state = parseMenuId(info.menuItemId); // синхронно
…
const openedPanel = shouldOpenPanel(state) && tabId !== undefined;
if (openedPanel) {
deps.openPanel(tabId).catch(warn('sidePanel.open')); // chrome.sidePanel.open({ tabId })
}
…
}
sourcecraft.dev
После клика, уже вне жеста, воркер вычисляет следующее состояние и перерегистрирует пункт: contextMenus.removeAll() и create() с новым id. contextMenus.update() тут не поможет: менять id он не умеет. Саму регистрацию меню Хром хранит сам, она переживает и выгрузку воркера, и перезапуск браузера. А вот при обновлении расширения Хром меню стирает, поэтому копия id лежит ещё и в storage.local, из неё пункт и восстанавливается.
Конечно, в id много не положишь - счётчик да режим. Зато до открытия панели не читается вообще ничего; это чуть быстрее чтения chrome.storage, хотя в UX разницы не заметить.
Если у вас тоже расширение с динамическим меню
Если вам тоже хочется, чтобы правый клик вёл себя по-разному, вот что у меня в итоге сработало:
вызов, которому нужен жест, ставьте первой строкой обработчика, до любого await;
всё, что нужно знать в момент клика, кладите в id пункта меню, а после клика перерегистрируйте пункт с новым id;
ну, вообще-то и вариант с колбэком тоже сработает
Само расширение - Save Image as PDF в Chrome Web Store, у него есть и сайт. А если нужно наоборот, PDF в картинки, - есть PDF to PNG, с которого всё и началось (сайт).
А у вас?
А вы как передаёте состояние в onClicked? Если нашли способ проще id и не завязанный на единственный колбэк - расскажите.
P.S. Впервые опубликовано в моём блоге agilesoftwaredevelopment.com.
Mentions
EPUB航海日誌 — 3.4の海図に合わせるだけの、短い航海のはずだった EPUB 3.4 が来年出るらしい。 電子書籍の形式である EPUB の、次の版である。 W3C の作業部会の予定表には、こう書いてある。 2027 February: EPUB 3.4 final version こちらは AozoraEpub3-JDK21 という、青空文庫のテキストや Web 小説を電子書籍にする道具を保守している。 今の EPUB 3.3 に合っているか見直し、次の 3.4 の草案も読んでおこう。 古いところを直せば終わりである。 サクッと済ませよう。 出港前に、予定の航路を書いておいた。 予定の航路 1. 仕様を読む 2. 古い要素を消す 3. 検証器が通ることを確かめる 4. 出す 4 行である。 短い航海の予定だった。 以前、RAW ファイルから表示用の画像を取り出す話を書いた。あのときは、カメラが作った実物に何度も予想を外された。 今回は国際標準がある。しかも原文まで読める。 今度こそ、まっすぐ進めるはずである。 本稿は、その航海の日誌である。 EPUB を知らなくても読めるように書く。途中で仕様の話が出るが、読めなくても船は進む。 先に結論の一部だけ言っておくと、予定の 4 行のうち、2 行目と 3 行目は出港した日に消えた。 出港前: 積荷を見ておく ■ 出港前 天候: 晴れ 位置: EPUB 3.4 が来年出ると知った 積荷: 縦書きの小説 予定: 短い航海 航海に出る前に、積荷を見ておく。 今回の積荷は、縦書きの小説である。 例 1: 完成品 AozoraEpub3-JDK21 で、宮沢賢治の『銀河鉄道の夜』を電子書籍にした。 青空文庫で公開されている作品である。 Apple Books で開くと、こうなる。 Apple Books(macOS)。縦書き、見出し、ルビ 縦書きである。 「言」の横に小さく「い」、「黒板」の横に「こくばん」が付いている。 ルビ(ふりがな)である。 紙の文庫本で見慣れた組み方が、画面に出ている。 文字を大きくしたり、読む端末を変えたりしても、本として読める。 これが EPUB の面白いところである。 例 2: 船の中身 では EPUB とは何か。 正体は、決まったルールで中身を詰めた ZIP ファイルである。 多くの環境では、拡張子を .zip に変えれば中身を開いて見られる。 『銀河鉄道の夜』の中身は、こうなっていた。 mimetype META-INF/container.xml OPS/package.opf ← 積荷目録 OPS/xhtml/nav.xhtml ← 目次 OPS/xhtml/title.xhtml ← 表題ページ OPS/xhtml/0001.xhtml ← 本文 OPS/xhtml/0002.xhtml OPS/css/vertical.css ← 縦書きの見た目 OPS/css/vertical_font.css OPS/css/vertical_text.css OPS/css/vertical_middle.css OPS/css/vertical_image.css .xhtml はウェブページである。ただし、正確には XHTML という、XML のルールに従って書く HTML である。 XML。 SOAP……うっ、頭痛が……。 昔の仕事で苦労した記憶が少しよみがえった。話を戻そう。 .css はウェブページの見た目の指定である。 つまり、 EPUB は、ざっくり言えばウェブページを ZIP に詰めた本である。 もちろん ZIP の中での置き方にも決まりはある。 本文はウェブページで書く。 見た目は CSS で決める。 何が入っているかは、package.opf という積荷目録に書く。 ほぼ、それだけである。 例 3: 変換の矢印 AozoraEpub3-JDK21 がやっていることは、この矢印である。 青空文庫のテキストには、ルビや見出しが記号で書いてある。 [#5字下げ]一 午後の授業[#「一 午後の授業」は中見出し] 「ではみなさんは、そういうふうに川だと言《い》われたり、乳《ちち》の流《なが》れたあとだと…… 言《い》 が「言」にルビ「い」である。 これを、EPUB の中ではウェブページの書き方にする。 <div class="chap2">一<span class="fullsp"> </span>午後の授業</div> <p>「ではみなさんは、そういうふうに川だと<ruby>言<rt>い</rt></ruby>われたり、<ruby>乳<rt>ちち</rt></ruby>の<ruby>流<rt>なが</rt></ruby>れたあとだと……</p> <ruby>言<rt>い</rt></ruby> が、例 1 の画面のルビになる。 例 4: 見た目は CSS が決めている 今度は同じ本文を横書きにしてみる。 同じ本文を横書きで 本文のウェブページは同じである。 変わったのは CSS だけである。 縦書き、ルビ、そして後で出てくる縦中横(「12」のような数字を、縦書きの中で 1 マスに横に並べる組み方)や傍点。 日本の小説に要るものは、どれも EPUB 3 では HTML と CSS の機能として書く。 EPUB 3 の概要文書にも、こうある。 EPUB 3 also supports CSS styles that enable both horizontal and vertical layout 縦書きとルビが EPUB に本格的に入ったのは、2011 年の EPUB 3.0 である。 それから十数年、3.0.1、3.1、3.2、3.3 と改訂が続いた。 3.3 からは、ウェブの標準を決めている W3C の勧告になった。 そして次が 3.4 である。 道具の素性も書いておく。 AozoraEpub3 は、2012 年に hmdev 氏が作り始めた道具である。青空文庫の注記を読んで、縦書きの EPUB を作る。 AozoraEpub3-JDK21 は、その派生版である。今の Java(Java 21)で動くようにし、Web 小説を電子書籍にする narou.rb から使えるように保守している。 本稿で「今の出力」と書くのは、AozoraEpub3-JDK21 の出力である。 ウェブページが作れるなら、電子書籍の中身も案外見慣れた世界にある。全く別の魔法ではない。 積荷の確認は、ここまで。 出港する。 出港の日: 海図を読む ■ 出港の日 天候: 晴れ/検証器は 0 件 位置: 3.3・3.4 の仕様を読み始めた 観測: 消すつもりだった要素は「消すべきもの」ではなかった 判断: 予定の航路の 2 と 3 が要らない 針路: 3.4 で何が変わるかを確かめる 予定の航路の 1 行目、仕様を読む。 計画書には、こう書いてあった。 古い 3 要素(表紙の meta・NCX・guide)を消して、警告を減らす。 EPUB 2 の時代の書き方が、今の出力に残っている。 それを消せば 3.3 対応、という算段である。 例 5: 積荷目録の実物 古い要素がどこにあるのか、実物を見ておく。 『銀河鉄道の夜』に表紙の画像を付けて、出港前の版(v1.7.6-jdk21)で変換した package.opf の抜粋である(「←」の注釈は本稿で足した)。 <metadata> <dc:title id="title">銀河鉄道の夜</dc:title> <dc:creator id="creator">宮沢賢治</dc:creator> <dc:language id="pub-lang">ja</dc:language> … <meta name="cover" content="img0000"/> ← 表紙の meta(EPUB 2 の書き方) </metadata> <manifest> <item id="nav" properties="nav" href="xhtml/nav.xhtml" media-type="application/xhtml+xml"/> <item id="sec0001" href="xhtml/0001.xhtml" media-type="application/xhtml+xml"/> … <item id="img0000" href="images/0000.png" media-type="image/png" properties="cover-image"/> <item href="toc.ncx" id="ncx" media-type="application/x-dtbncx+xml"/> ← NCX(EPUB 2 の目次) </manifest> <spine page-progression-direction="rtl" toc="ncx"> ← 右から左へめくる … </spine> <guide> ← EPUB 2 の書き方 … <reference type="text" title="sec0001" href="xhtml/0001.xhtml"/> </guide> <manifest> が、文字どおり積荷目録である。 表紙の meta、NCX、<guide>。計画書の「古い 3 要素」は、この 3 つである。 表紙は、EPUB 3 では properties="cover-image" の付いた行で示す。表紙の meta は、同じことを EPUB 2 の書き方で言っている。 <guide> は EPUB 2 の書き方で、「本文はここから」と港に教える行である。 これを消せばいい。 そう思って、EPUB 3.3 の原文を読んだ。 消す理由が無かった 3.3 の原文では、これらは deprecated(使うべきでない)ではなかった。 legacy だった。 「昔の EPUB 2 のリーダーのために残してある。入れてもよいが、EPUB 3 のリーダーは使わない」という扱いである。 しかも、EPUB Reading Systems 3.3 には、3.3 に準拠した本の legacy 要素をリーダーがサポートしてはならない、とある。 つまり作る側には「入れてもよい」、読む側には「使わないでほしい」と書かれている。 検証器も、legacy 要素があるだけでは警告しない。 念のため、手元の継続的なビルド(CI)が作っている EPUB を確かめた。 最初から 0 errors / 0 warnings だった。 減らす警告が、そもそも無い。 予定の航路の 2 行目「古い要素を消す」は、消す理由を失った。 3 行目「検証器が通ることを確かめる」は、最初から通っていた。 出港した日に、予定の 2 行が消えた。 3.4 の海図で見直したところ では、次の 3.4 はどうなのか。 3.4 の草案(2026 年 11 月 2 日版)の変更記録を読んだ。 3.4 で新しく整理されることもあれば、3.3 の時点ですでに決まっていたこともあった。 一つ目。3.4 では「outdated(古くなった機能)」という区分に整理される。 推奨はされない。でも今すぐ使えなくすると、世の中にある大量の EPUB が仕様に合わなくなる。そこで残される機能である。 さっきの legacy の要素も、この区分に含まれる。 二つ目。読む側には、以前から決まりがあった。 EPUB 3.3 の時点で「リーダーは legacy 要素を使わない」とされている。3.4 で初めて禁止されるわけではない。 だから仕様どおりなら、<guide> のような古い標識がなくても、新しい標識で本を開けるはずだった。 三つ目。縦書きの CSS にも、古い書き方が残っている。 ここは例を見たほうが早い。 例 6: 同じことを 4 回書いている CSS AozoraEpub3-JDK21 の縦中横の指定である(コメントは本稿で足した)。 .tcy span { text-combine-upright: all; /* 今の標準 */ text-combine: horizontal; /* 古い書き方 */ -webkit-text-combine: horizontal; /* ブラウザ用の古い書き方 */ -epub-text-combine: horizontal; /* EPUB 用の古い書き方 */ } 同じことを 4 回書いている。 リーダーによって、分かる書き方が違った時代の名残である。 縦書きも傍点も、同じように 3 回ずつ書いてある。 3.4 では、多くの -epub- で始まる書き方が outdated に整理される。 「CSS の対応が追いついたら、頭に何も付かない標準の書き方に移ってほしい」という方向である。 ただし最後の -epub-text-combine は別枠だ。これは 3.3 の時点ですでに deprecated だった。 四つ目。ルビの <rp> も確かめた。 <rp> は、ルビを理解しない画面のための保険である。 <ruby>漢字<rp>(</rp><rt>かんじ</rt><rp>)</rp></ruby> ルビを理解しない画面では「漢字(かんじ)」と括弧つきで出る。 ルビを理解する画面では、<rp> は表示されない。 3.3 の時点で、EPUB 3 のリーダーはルビを理解するので <rp> は使わないほうがよい、と勧めている。3.4 でもその考え方が続いている。 AozoraEpub3-JDK21 は、もともと <rp> を出していない。 Web 小説のページに <rp> 付きのルビがあれば、取り込むときに外している。 ここは新しい変更への対応というより、今の作りを確かめ直しただけだった。 ほかにも 3.4 では、絵の形式に AVIF と JPEG XL が加わったり、漫画を縦に流して読む形式が加わったりする。 ただ、縦書きの小説には直接は効かない。 ちなみに 3.4 の作業部会の名簿には、講談社、KADOKAWA、メディアドゥ、ボイジャー、楽天といった日本の会社が並んでいる。 海図を書いている人たちの中に、縦書きの国の人がいる。 方針 ここで方針を決めた。 今作った本が、将来も安心して残せる形に。そのうえで、できるだけ過去との互換を保つ。 既定は新しい形にする。 古い形は設定で戻せるようにする。 ここまでは海図の上の話である。 ところで、海図に「古い」と書いてある航路を、実際の港は使っていないのだろうか。 海図だけでは決められない。 港に寄って確かめることにした。 予定に無い寄港である。 海図は、まだ鉛筆で書かれている。 寄港: 4 つの港に寄る ■ 寄港 天候: 晴れ/検証器は 0 件 位置: 試作の本を 4 種類作った 観測: 1 つの港だけ、古い航路を使っていた 判断: 海図で古いと書かれた行でも、消せないものがある 針路: 目次も確かめる 寄った港は 4 つである。 Apple Books(macOS) Google Play ブックス(Android) Thorium(Windows。EPUB 専用のリーダー) Kindle Previewer(Windows。Kindle で見たときの見た目を確かめる道具) 試作の本を 4 種類作った。 A: 今の形 B: 新しい形(古い要素を外したもの) C: CSS から -epub- などの古い書き方を全部外したもの N: 対照 N は「比べれば差が見えるか」を確かめるための本である。 わざと崩しておいて、4 つの港で崩れが見えることを先に確かめた。 崩した本で差が見えなければ、A と B に差が無くても「差が無い」とは言えない。 計器が動いているかを、先に確かめておく。 例 7: 縦中横 ここで縦中横を見ておく。 青空文庫のテキストでは、こう書く。 あいう!?えお |29《二十九》[#「29」は縦中横] EPUB の中では、こうなる。 <p>あいう<span class="tcy"><span>!?</span></span>えお</p> <p><span class="tcy"><span><ruby>29<rt>二十九</rt></ruby></span></span></p> そして画面では、こう出る。 「!?」と「29」が、縦の 1 マスに収まる この class="tcy" に、例 6 の 4 行が効いている。 浅瀬 1: Play ブックスは、古い航路を使っていた C の本、つまり古い書き方を全部外した本を開いた。 Apple Books、Thorium、Kindle Previewer は変わらなかった。 縦書きも、ルビも、傍点も、縦中横も、頭に何も付かない標準の書き方で出た。 Google Play ブックスだけが、縦中横を崩した。 左: -epub-text-combine あり。右: 標準の text-combine-upright だけ 切り分けるために、古い書き方を 1 種類ずつ残した本を作った。 見る前に、予測を書いておいた。 「-webkit- だけ残した本は組まれる。-epub- だけ残した本は崩れる」 両方とも外れた。 -webkit- だけの本は崩れた。 -epub- だけの本は、縦中横が組まれた。 画面は、古い書き方を全部残した本と画素まで一致した。 例 6 の最後の行、-epub-text-combine である。 3.3 の時点ですでに「使うべきでない」とされている書き方である。 これは、標準の CSS だけにすればきれいに片付く、という予想を覆した。 もう一つあった。 <guide> を外した本は、Play ブックスで開くと表紙から始まった。 <guide> がある本は、本文から始まる。 例 5 で「これを消せばいい」と思った行である。 実は EPUB 3 には、<guide> の後継がある。 目次のファイル(nav.xhtml)の中の、landmarks という一覧である。 AozoraEpub3-JDK21 は、両方を出している。 <!-- EPUB 3 の書き方(nav.xhtml の中) --> <nav epub:type="landmarks"> <ol> <li><a epub:type="bodymatter" href="0001.xhtml">本文</a></li> </ol> </nav> <!-- EPUB 2 の書き方(package.opf の中。legacy) --> <guide> <reference type="text" title="sec0001" href="xhtml/0001.xhtml"/> </guide> どちらも「本文はここから」と言っている。 <guide> を外しても、landmarks は残っていた。 それでも Play ブックスは、表紙から開いた。 <guide> だけを戻すと、本文から開いた。 新しい港には新しい標識が立っている。 少なくとも今回の Google Play ブックスは、出発地点を決めるとき、古い標識に頼っていた。 海図には「使わない」と書いてあるのに、その標識がないと出発地点が変わった。 どちらも残した。 浅瀬 2: 目次の階層は、読まれない場所にあった 港に寄ったついでに、目次パネルも見た。 『銀河鉄道の夜』の目次はこうである。 Apple Books の目次パネル 章が並んでいる。 では、「第一部」の下に「第一章」がぶら下がるような、階層のある本はどうか。 4 つの港とも、平らだった。 AozoraEpub3-JDK21 の出力では、目次の階層は NCX という EPUB 2 の目次にしか書かれていなかった。 今回の 4 つのリーダーは、EPUB 3 の目次である NAV を使っていた。 誰にも読まれない場所に、階層を書いていた。 調べると、元の AozoraEpub3 は 2012 年の時点で、NAV にも階層を書いていた。 止めたのは、私である。 2025 年 12 月、AozoraEpub3-JDK21 で、NAV の階層を出さないようにした。 少し言い訳をしておく。 私がこの派生版を触り始めたのは、2025 年である。当時は、プログラム全体の構造も EPUB の仕様も、まだよく分かっていなかった。 それなのに、目次には悩まされていた。小説によっては目次が途中で切れる。目次のせいで、正しい EPUB が作れないこともあった。 そこで、こう考えた。 「もう、フラットにしちゃえ!」 階層をなくせば、ひとまず目次で本が壊れることは避けられる。そう思って直した。 元の書き方は、こうだった。 <li><a>第一部</a></li> <li><ol> ← 見出しの無い <li> の中に、子の目次 <li><a>第1章</a></li> </ol></li> 「第一部」と、その子の「第1章」が、別々の <li> に入っている。 NAV の規則では、子の目次は、親の見出しのすぐ後ろに置く。 この形は、NAV の規則に合わなかった。 本当は、その書き方を直せばよかった。 だが当時の私は、階層を出すこと自体を止めた。 規則には合うようになった。 目次は平らになった。 階層は NCX にだけ残った。 今回のリーダーが読まない場所にしか階層を残さなかったのは、私のバグである。 1 年近く、気づかなかった。 今になって過去の自分の修正を読み返すと、正直、ちょっと恥ずかしい。 今回は、規則どおりの書き方で戻した。 <li><a>第一部 旅立ち</a> <ol> <li><a>第1章 春の朝</a></li> </ol> </li> 親の <a> のすぐ後ろに、子の <ol> を置く。 違いは、<li> の閉じる位置だけである。 左: 今までの出力。右: NAV に階層を入れた出力 予定に無い寄港、4。予定に無い修理、1。 夜の修理: 全ページ画像の本 ■ 夜の修理 天候: 晴れ/検証器は 0 件 位置: 画像だけの本を直した。検証器は黙った 観測: Apple Books が、横長のページの右端を切っている 判断: 検証器の沈黙は、無事の知らせではない 針路: ページの大きさを本全体で 1 つにする ここで航海が、予定から大きく外れる。 AozoraEpub3-JDK21 は、小説だけでなく、画像をまとめた本も作れる。 漫画やスキャンした本のように、全ページが画像の本である。 小説の話をしていたので、見る予定は無かった。 ついでに 1 冊作って、検証器にかけた。 エラーが 12 件出た。 この本を港に持って行くと、Apple Books で絵の上下が切れていた。 Apple Books。左: 直す前(上下が切れる)。右: viewport を入れた後(絵の大きさがそろった本) Google Play ブックスでは、絵が出なかった。 Google Play ブックス。左: 直す前(絵が出ない)。右: 直した後 CI の EPUB は 0 warnings だった。 CI は、画像だけの本を作っていなかった。 これは検証器の失敗ではない。 検証する本の種類を、こちらが狭くしていた。 晴れていたのは、その海域に船を出していなかったからである。 直した。晴れた。切れていた 足りなかったのは、ページの大きさの指定(viewport)だった。 画像だけの本は、1 ページ 1 枚の絵を、決まった大きさのページに置く。 その大きさを書いていなかった。 ページごとに、その絵の大きさを書いた。 <meta name="viewport" content="width=1200, height=1600"/> 各ページの <head> に入る、この 1 行である。 検証器にかけた。 0 件。晴れた。 念のため、港でも見ることにした。 ただし普通の漫画の絵では、どこが切れているのか分かりにくい。 そこで、ページの縁に赤い枠とページ番号を描いた試験用の絵を作った。 縦長、横長、正方形、極端に横に長い絵を混ぜた。 赤い枠が 4 辺とも見えていれば、切れていない。 Apple Books で開いた。 1 ページ目より横に長いページの、右端が切れていた。 極端に横に長いページでは、ページ番号ごと見えなかった。 最初は「直す前も切れていたのだから、悪化ではない」と判断した。 これは誤りだった。 同じ赤い枠の本を、直す前の版と直した後の版で作って並べた。 直す前の版では、縦長と正方形のページの下が切れていた。 直した後の版では、1 ページ目より横に長いページの右端が切れていた。 切れるページと、切れる向きが入れ替わっただけだった。 それを「同じく切れている」で片付けていた。 そこで作り直した。 ページの大きさを、本全体で 1 つにした。 縦長の絵も横長の絵も、そのページの中に、縦横の比を保ったまま置く。 4 つの港とも、赤い枠が 4 辺とも見えた。 Apple Books、赤い枠の試験用の本。上: 直す前(縦長と正方形のページの下が切れる)。中: ページごとに大きさを書いた版(1 ページ目より横に長いページの右端が切れ、極端に横長の 5 ページ目は番号ごと見えない)。下: 本全体で 1 つにした版(4 辺とも見える。絵の並びは上・中と少し違う) 検証器の 0 件は、絵が切れていないことを教えない。 仕様に合うことと、人が開いてきちんと読めることは、別の試験だった。 天候、晴れ。ただし港の桟橋が、一つ足りない。 もう一度海図へ: 消しても変わらないことを測る ■ 再出発 天候: 晴れ 位置: 古い要素を、既定で出さないことにしたい 観測: 出しても出さなくても、4 つの港で何も変わらない 判断: 既定で外す。設定で戻せるようにする 針路: 本物の積荷で、もう一度 予定の航路の 2 行目「古い要素を消す」に、ようやく戻ってきた。 ただし、全部ではない。 浅瀬 1 で、<guide> は Play ブックスが使っていると分かった。 残りの 2 つ、表紙の <meta name="cover"> と、NCX の目次(toc.ncx)はどうか。 消す前に、消しても変わらないことを測った。 消した本と消さない本を 4 つの港で開き、見比べた。 表紙(本棚のサムネイル) 開く位置 目次 最初のページ どこも変わらなかった。 Apple Books と Play ブックスでは、最初のページの画面が画素まで一致した。 小さな浅瀬が一つあった。 Kindle Previewer の本の情報の画面は、toc.ncx が無い本でも「NCX: Specified(あり)」と表示する。 表示を見て「ある」「ない」を判断すると、間違える。 表紙の meta と NCX は、既定で出さないことにした。 古いリーダーのために、設定で戻せるようにした。 確かめようのない港(Kobo)向けの出力は、今までどおり出す。 出す前に: 本物の積荷で、もう一度 ■ 出す前 天候: 晴れ/試験は全部通る 位置: 実際の作品データで、もう一度 4 つの港を回る 観測: 規格とは別の不具合が 4 つ 判断: 試験用の本では、見つからなかったものばかり 針路: 直してから出す ここまで港に持って行ったのは、試験用に作った本だった。 でも、AozoraEpub3-JDK21 は、実際の作品を電子書籍にする道具である。 ならば、本物のデータでも試しておこう。 いくつかの作品データを EPUB に変換し、4 つの港で確かめた。 同じデータを前の版でも変換して比べた。今回の変更によって、本文や画像が欠けたり、目次の項目が失われたりする問題は見つからなかった。 ところが、以前から潜んでいた別の不具合が 4 つ見つかった。 その 1: 最初の話が、2 回始まる ある作品では、最初の話が EPUB の中に 2 回入っていた。 調べると、変換元に同じ話を指すリンクが 2 つあった。 同じものを 2 回たどって、2 回取り込んでいたのである。 本の先頭から同じ話が繰り返される。読む側からすれば、何が起きたのか分からない。 その 2: 「第三話」の下に、後の話が全部ぶら下がる 本文に「第三話」と書かれた行があると、目次の上でその行が親になり、後に続く話が全部その下に入ってしまう本があった。 目次を階層表示に戻したことで、表に出た問題だった。 平らな目次なら分かりにくい。階層にすると、どの見出しを親子として扱っているかが、はっきり見えてしまう。 NAV を直したから終わり、ではなかった。今度は、目次にする見出しそのものを確かめる必要があった。 その 3: Apple Books の目次が、特定の文字で壊れる 目次の項目名に & や < があると、Apple Books の目次パネルにエラーが出た。 項目が途中で途切れ、ページ番号まで消えてしまう。 ところが、EPUB の検証器は通る。 ここは、1 か所だけ変えた本を何冊も作って切り分けた。 &、<、]]> では壊れる > や引用符では壊れない 少なくとも、XML が単純に壊れていて本を開けない、という話ではなかった。 この版では、目次の項目名に限って問題を起こす文字を全角に置き換えた。本文の文字は変えていない。 表示を回避するための処置であって、Apple Books の内部で何が起きているかまで解明できたわけではない。 左: 項目名に &。右: 全角の & その 4: 「底本:」まで目次に出てくる 作品の末尾に付いていた「底本:」で始まる行が、見出しとして扱われていた。 しかも、一部の作品だけではない。調べた本の目次に、同じように現れていた。 本文の情報として残ることと、読者が選ぶ目次の項目になることは違う。 これも、試験用の本では気づかなかった。 4 つとも直した。 どれも、最初に用意した試験用の本では見つからなかったものだった。 本物の積荷は、試験の積荷より重い。 こうして v1.8.0-jdk21 を出した。 その夜、スマホで文字を大きくすると表題ページで題名と著者が重なる、という不具合も直した。 帰港 ■ 帰港 位置: v1.8.0-jdk21 を出した 予定の航路: 4 行 実際の航路: 下のとおり 出港前の予定は、4 行だった。 予定の航路 1. 仕様を読む 2. 古い要素を消す 3. 検証器が通ることを確かめる 4. 出す 実際の航路は、こうなった。 実際の航路 1. 仕様を読む → 消す理由が無かった。検証器は最初から 0 件だった 2. 4 つの港に寄る → Play ブックスだけ、古い縦中横の書き方と <guide> に頼っていた → 目次の階層を、今回のリーダーが読まない場所に書いていた(私のバグ) 3. 予定に無かった、全ページ画像の本の修理 → 検証器のエラー 12 件。直して 0 件。それでも Apple Books で横長のページが切れていた 4. 表紙の meta と NCX を消しても変わらないことを、4 つの港で測る 5. 本物の積荷で、もう一度 → 規格と関係ない不具合が 4 つ 6. v1.8.0 を出す 日本の縦書きの小説について、分かったことをまとめておく。 縦書き・ルビ・傍点は、頭に何も付かない標準の CSS で、4 つの港とも出た。古い書き方も、当面は並べて書いておく 縦中横は、Play ブックスがまだ「使うべきでない」とされる書き方に頼っている。だから残す 表紙の meta と NCX(EPUB 2 の名残)は、出さなくても 4 つの港で何も変わらなかった。既定で外し、設定で戻せる <guide> も EPUB 2 の名残だが、Play ブックスが開く位置に使っている。EPUB 3.3 の仕様では使わないはずなのに、実機のために今は残す 航海で分かったことは、3 つである。 海図(仕様の区分)は、港(リーダー)が何に頼っているかを教えない。 晴れ(検証器の 0 件)は、積荷が無事だという知らせではない。 本物の積荷には、試験用の本が教えてくれないことがある。 検証器が 0 件と言っていたのは、検証器に渡した本についてである。画像だけの本を渡していなかったのは、こちらだった。 前回の RAW の話では「一番大きい JPEG を探せばいい」という前提が崩れた。 今回は「仕様どおりなら読めるはずだ」という前提が崩れた。 どちらも、実物を開かなければ分からなかった。 ここまで書くと、ひどい目に遭ったように見えるかもしれない。 でも、面白かった。 お客さんや営業が、後ろに立っていないからである(笑)。 仕事なら「いつ直るの?」と聞かれそうなところでも、今回は「なんでこうなるんだろう?」と寄り道できる。予想が外れたら、別の本を作って、また試せばいい。 昔の自分が直したはずのところで転んだのは、少し恥ずかしかったけれど。 港にも事情がある 4 つの港を回って、もう一つ思ったことがある。 リーダーを作っている人たちも、大変なのだろう。 EPUB の仕様は更新されていく。 一方で、一度出版された電子書籍は、そのまま何年も、何十年も残る。紙の本と同じで、古いからといって、読む人の本棚から消えるわけではない。 読む側にしてみれば、昨日まで読めていた本が、リーダーの更新で急に読めなくなったら困る。 新しい仕様には対応しなければならない。でも、昔の本も読み続けられるようにしたい。 この両立は、なかなか難しいはずだ。 今回見つけた違いが、すべて互換性のために意図されたものとは限らない。単に未対応のところも、不具合もあるだろう。各社の事情までは分からない。 それでも、Google Play ブックスが古い指定を使っていたことは、最初とは少し違って見える。 そういうこちらも、最初は古い要素を消すつもりで出港して、結局、いくつか残して帰ってきた。 船を作る側にも、港を守る側にも、事情がある。 そういえば、EPUB の中身は ZIP だった。 もし手元に、自由に開ける EPUB があったら、一度中をのぞいてみてほしい。 本文の HTML や CSS が、そこに入っている。 普段何気なく読んでいる電子書籍も、こうして作られている。 EPUB 3.4 は、まだ草案である。 海図は、これからも書き換わる。 そのときは、また港に寄ってみよう。 短い航海の予定だった。 資料: 海図の細かい文字が気になる人へ ここからは、試した環境と原典である。物語はもう帰港している。 寄った港の版: Apple Books(macOS)、Google Play ブックス 2026.10.2.1(Android)、Thorium 3.5.1(Windows)、Kindle Previewer 4.0.3.0(Windows。Tablet・Phone・E-reader)。v1.8.1-jdk21 を出した後、Send to Kindle 経由で Kindle 端末と iPhone の Kindle アプリでも確かめた 寄っていない港: Kobo(端末が無い)。Kobo 向けの出力は今までの形を残している 検証器: epubcheck 5.3.0 と 5.4.0 EPUB の仕様(W3C) EPUB 3.3(2026 年 1 月 13 日の勧告版) 5.9 Legacy features: 表紙の meta、<guide>、NCX 6.1.4.3.2 The rp element 6.3.1.3 Prefixed properties(-epub- で始まる CSS) Expressing the ICB in XHTML(画像だけの本の viewport) EPUB Reading Systems 3.3 5.7 Legacy features(「Reading systems MUST NOT support legacy features……」) EPUB 3.4(2026 年 11 月 2 日の候補勧告草案。最新版) EPUB Reading Systems 3.4 EPUB 3 Overview 3.4 の予定: Publishing Maintenance Working Group Charter(「2027 February: EPUB 3.4 final version」) EPUB が借りている、ウェブの仕様 CSS Writing Modes Level 3(縦書き。縦中横は text-combine-upright) HTML の ruby 要素(<rt> と <rp>) 日本語組版処理の要件(W3C。縦書き・ルビ・縦中横などを、日本語の組版の側から整理した文書) 道具と作品 EPUB 3.3 と 3.4 への対応(要素ごとの一覧と、リーダーごとの違い) 最新版(v1.8.1-jdk21) AozoraEpub3(hmdev 氏の原作) AozoraEpub3-JDK21 青空文庫の注記一覧([#…] や 《》 の書き方) 例に使った作品: 宮沢賢治『銀河鉄道の夜』(青空文庫)
today is a sad day for the web, and for a11y. but on the bright side, hopefully this is the last time i'll have to explain why buttons and controls should not have `cursor: pointer` alas, it is tiring trying to help you dorks. csswg-drafts#14251
Why your INP can fall after one more click By Aditya Reddy Say you have a page with 49 interactions. The slowest took 480 ms, followed by 320 ms and 240 ms. Everything else was faster. Your INP is 480 ms. Now click something fast. You have changed no code, and that slow interaction still takes 480 ms. The score for the visit drops to 320 ms anyway. That's the part of INP I wanted to explain in my Event Timing specification PR. The PR is still open. A single number on a dashboard hides quite a bit of bookkeeping. What happened at click 50? INP skips one longest interaction for every complete group of 50 interactions. At 49, it picks the longest. At 50, it picks the second longest. The INP guide explains the reason for excluding these outliers on pages with lots of interactions. For our example, assuming all the extra interactions are below 240 ms: | Interactions so far | Longest values skipped | INP candidate | | --- | --- | --- | | 49 | 0 | 480 ms | | 50 | 1 | 320 ms | | 99 | 1 | 320 ms | | 100 | 2 | 240 ms | So you can cross two of these boundaries and go from 480 to 240 ms without fixing the 480 ms interaction. Nice dashboard, same slow click. The selection code in web-vitals amounts to this, with the latencies sorted from longest to shortest: const skip = Math.floor(interactionCount / 50); const index = Math.min(skip, descendingLatencies.length - 1); const candidate = descendingLatencies[index]; You need at least one candidate for this fragment. It is only the selection step, not a complete collector. web-vitals retains up to ten of the longest interactions, so the list can be much shorter than the interaction count. Count interactions, not event entries There is another place to get the numbers wrong before you even reach that selection. A click can leave several PerformanceEventTiming entries. Take these made-up values from one visit: | Interaction ID | Event | Duration | | --- | --- | --- | | 7 | pointerdown | 320 ms | | 7 | pointerup | 296 ms | | 7 | click | 280 ms | | 14 | click | 480 ms | | 21 | keydown | 240 ms | The first three rows belong to one interaction. Its latency is 320 ms, the longest duration in that group. Adding them gives 896 ms, but those durations overlap. Five rows here give us three interactions, with latencies of 320, 480 and 240 ms. Group by the nonzero interactionId. Leave entries with ID zero out of this calculation. The Event Timing spec describes how those IDs group related events. Your observer may have missed some of it For entries of type event, the default duration threshold is 104 ms. You can register an observer with durationThreshold: 16 to receive shorter events going forward. That does not bring back all the fast events from before registration. Even with buffered delivery, you only get the earlier entries that were kept under the default threshold. This is covered in the spec's measurement section. That's why the total interaction count and your observed list length need to stay separate. A fast interaction can increase the count without appearing in that list. Lowering the threshold late won't recover its duration. I would use web-vitals for production collection. There is first-input handling, page visibility and back/forward cache restoration to account for too. Code running in one window also cannot see every interaction in an embedded frame. When debugging the score, keep the selected interaction's ID and latency along with the total count. If the score drops, check which interaction it picked. And if you are fixing that 480 ms click, compare the click itself before and after the change under similar conditions. The proposed spec section puts the grouping, selection, observer threshold and retained-list limit together. The fast click at interaction 50 is a useful example of why those details matter.
you are all so fucking dumb 🤦♂️ csswg-drafts#14251
buttons, selects and checkboxes are getting the hand cursor by default. interesting 🎭 csswg-drafts#14251 t.co
The debate is over, and evil has won. `cursor: pointer` now means clickable. RIP my ability to tell whether ⌘-clicking something will open it in a new window. csswg-drafts#14251
Cursors for base appearance. CSSWG settled the long-standing debate: form controls with appearance: base get cursor: pointer when clickable, grab on range sliders, and not-allowed when disabled. #css #html csswg-drafts#14251
transform-inverse() and transform-transpose() I propose a transform-inverse and transform-transpose function to be added to CSS. It could greatly reduce the complexity of some 3D scenes, like ones with billboards, and would allow correct interpolation in animations
Правый клик с памятью: почему я храню счётчик в id пункта меню Chrome Мои хобби-проекты - небольшие расширения для Хрома и сайты-микросервисы. Одно из расширений, PDF to PNG, внезапно забралось на первое место в поиске Chrome Web Store по запросу “pdf to png” (ну, почти всегда), и им пользуются аж под 500 человек - это потрясающе! Раз PDF в картинки получилось, немедленно загорелось сделать и наоборот - картинки -> PDF, только ещё круче: чтоб работало через правый клик по картинке на любой странице, чтоб была лёгкая(!) возможность настроек - и чтоб всё прям локально в браузере и картинки при этом никуда не загружались! Все замечательные настройки поместил в сайд-панель, вот только обычный пользователь может же никогда не догадаться её открыть, а таки хотелось бы дать о ней знать, но при этом не дёргать тех, кому достаточно меню правой кнопки мыши - что ж, будем показывать её иногда (ну или пока пользователь явно не кликнет куда-то в панель). Эта хотелка упёрлась в правило Хрома: открыть панель можно только в ответ на клик, а в обработчике клика по пункту меню это значит - до первого await. А чтобы показывать иногда, на каждом клике расширение должно помнить, сколько раз уже её показало, - а прочитать это из хранилища и есть await. Решение - хранить счётчик прямо в id пункта контекстного меню! :) И вот - Save Image as PDF. Правый клик - и картинка уже PDF. Правый клик по картинке → Save image as PDF Await страницы и await воркера по-разному “сжигают” жест Правый клик случается на чужой странице, где от расширения ничего не открыто: ни панели, ни своих страниц. Соответственно, клики по пункту меню слушает сервис-воркер. Наивный подход - прочитать в обработчике клика, сколько раз панель уже показывали, и по этому решить, открывать ли её: chrome.contextMenus.onClicked.addListener(async (info, tab) => { const { shown = 0 } = await chrome.storage.local.get('shown'); if (shown < 3) { chrome.sidePanel.open({ tabId: tab.id }); } }); sourcecraft.dev Панель, конечно же, не откроется ни разу, а в консоли воркера на каждый клик будет ошибка sidePanel.open() may only be called in response to a user gesture. Некоторые API Хрома, например chrome.sidePanel.open() и chrome.permissions.request(), работают, только если их вызвали в ответ на действие человека: клик, нажатие клавиши. Клик по пункту меню таким жестом считается, вот только живёт этот жест на странице и в воркере очень по-разному. На обычной странице работает transient activation: после клика жест “живёт” 5 секунд. Так прямо и записано в коде Chromium, kActivationLifespan = base::Seconds(5), с пояснением “long enough to allow network round trips even in a very slow connection”. Проверить легко - вставьте в консоль DevTools на любой странице и кликните по ней: document.onclick = () => [4000, 6000].forEach((ms) => setTimeout(() => console.log(ms, navigator.userActivation.isActive), ms)); // 4000 true // 6000 false sourcecraft.dev А в воркере никакого таймера нет: жест держится, только пока Хром передаёт событие клика обработчику. async-обработчик на первом же await возвращает управление Хрому - и жеста больше нет. Даже await Promise.resolve(), который не ждёт вообще ничего, его сжигает. Сам await chrome.sidePanel.open() при этом можно - главное, чтобы перед ним ничего не ждали. В команде Chrome об этой разнице знают: в WebExtensions CG предлагают дать расширениям жест по времени, как на страницах, но пока это только предложение. Почти любое полезное действие требует await: прочитать настройки, скачать картинку, собрать PDF. К тому же воркер мог только что проснуться от этого самого клика: в MV3 его выгружают примерно после 30 секунд простоя - так себе детерминизм. После такого холодного старта в памяти пусто, так что и глобальная переменная не спасёт. Как же тогда узнать в момент клика, сколько раз панель уже показывали? Колбэк вместо await На chromium-extensions это можно обходить старым добрым колбэком, ибо вот именно для одного колбэка Хром оставляет “жест”, но только для одного, и только для колбэка (никаких await!). Колбэк внутри колбэка жеста уже не получит. chrome.contextMenus.onClicked.addListener((info, tab) => { chrome.storage.local.get('shown', ({ shown = 0 }) => { if (shown < 3) chrome.sidePanel.open({ tabId: tab.id }); // так сработает если колбэк именно один - без вложенности }); }); sourcecraft.dev Однако получилось придумать другой трюк и остаться в мире await! Счётчик в id пункта меню Я придумал такое правило, чтобы насильно наверняка познакомить пользователя с сайд-панелью, но не мозолить глаза постоянно: открывать панель на первых трёх сохранениях, потом на каждом десятом, пока пользователь не ткнёт хоть что-то в панели или не откроет её сам (ну то есть - панель точно была замечена). Для этого уже в момент клика нужно знать, какое это по счёту сохранение и какой режим выбран в настройках. Есть одно своё значение, которое приходит в обработчик клика по меню синхронно, - это, собственно, info.menuItemId, id пункта, по которому кликнули. Это может быть неочевидным, но вообще-то ничего не мешает пересоздавать пункты меню хоть двадцать раз в день. Вот туда состояние и легло: // id = 'save|<step>|<mode>' // step 0..12 пользователь ещё не пользовался панелью; считаем сохранения через меню // known пользователь открыл панель сам или что-то в ней сделал; больше не меняется // mode download | panel | collection export const NUDGE_SAVES = 3; export const LAST_STEP = 12; export const KNOWN = 'known'; const ID_PATTERN = /^save|(known|0|[1-9]\d?)|(download|panel|collection)$/; export function parseMenuId(id: unknown): MenuState | null { … const match = ID_PATTERN.exec(id); … return { step, mode: match[2] as MenuMode }; } export function shouldOpenPanel(state: MenuState): boolean { if (state.mode === 'panel') return true; if (state.step === KNOWN) return false; return state.step < NUDGE_SAVES || state.step === LAST_STEP; } export function afterMenuSave(state: MenuState): MenuState { const { step } = state; if (step === KNOWN) return state; return { ...state, step: step === LAST_STEP ? NUDGE_SAVES : step + 1 }; } sourcecraft.dev Пункт меню у расширения один, с одним и тем же заголовком “Save image as PDF”, меняется только id: save|0|download, save|12|collection, save|known|panel и т.п. Обработчик разбирает id и, если надо, открывает панель первым же вызовом, ничего не дожидаясь: export function handleMenuClick(info, tab, deps): Promise<void> { const state = parseMenuId(info.menuItemId); // синхронно … const openedPanel = shouldOpenPanel(state) && tabId !== undefined; if (openedPanel) { deps.openPanel(tabId).catch(warn('sidePanel.open')); // chrome.sidePanel.open({ tabId }) } … } sourcecraft.dev После клика, уже вне жеста, воркер вычисляет следующее состояние и перерегистрирует пункт: contextMenus.removeAll() и create() с новым id. contextMenus.update() тут не поможет: менять id он не умеет. Саму регистрацию меню Хром хранит сам, она переживает и выгрузку воркера, и перезапуск браузера. А вот при обновлении расширения Хром меню стирает, поэтому копия id лежит ещё и в storage.local, из неё пункт и восстанавливается. Конечно, в id много не положишь - счётчик да режим. Зато до открытия панели не читается вообще ничего; это чуть быстрее чтения chrome.storage, хотя в UX разницы не заметить. Если у вас тоже расширение с динамическим меню Если вам тоже хочется, чтобы правый клик вёл себя по-разному, вот что у меня в итоге сработало: вызов, которому нужен жест, ставьте первой строкой обработчика, до любого await; всё, что нужно знать в момент клика, кладите в id пункта меню, а после клика перерегистрируйте пункт с новым id; ну, вообще-то и вариант с колбэком тоже сработает Само расширение - Save Image as PDF в Chrome Web Store, у него есть и сайт. А если нужно наоборот, PDF в картинки, - есть PDF to PNG, с которого всё и началось (сайт). А у вас? А вы как передаёте состояние в onClicked? Если нашли способ проще id и не завязанный на единственный колбэк - расскажите. P.S. Впервые опубликовано в моём блоге agilesoftwaredevelopment.com.
disabled を aria-disabled に変えたら、別のWCAG基準が効き始めた ! 2作目のポートフォリオとして「Hubpin」(分散した発信を1か所に集めるハブサイト)を作りながら書いています。 リポジトリ: Matsutake29/hubpin Next.js(App Router) + Supabase + Tailwind CSS カードを並び替えるボタンで、こういう不具合がありました。 押した後にフォーカスが消える。 キーボードで操作している人は、ここで現在地を失います。 矢印ボタンを連打して並べ替えたいのに、1回押すたびにページの先頭へ戻される形でした。 切り分けで、仮説が1つ消えた 最初は「Server Action の再レンダリングで DOM ごと作り直されているのでは」と考えていました。 キーボードで一通り試したら、違いました。 消えるのは、移動先が1番目のときだけでした。 3番目から2番目へ動かしたときはフォーカスが残っています。 1番目のカードは、上へ動かせないので「上へ」ボタンを disabled にしていました。 つまり 押したボタンが、押した直後に disabled になる。 disabled な要素はフォーカスを保持できません。 これは HTML の仕様で決まっていることで、ブラウザの挙動の差ではありませんでした。 aria-disabled に変えた。ここまでは定石 WAI-ARIA の推奨どおり、disabled 属性をやめて aria-disabled="true" にしました。 aria-disabled は「無効だ」と支援技術に伝えるだけで、要素自体は生きています。 フォーカスは保持され、スクリーンリーダーは「無効」と読み上げます。 押せないボタンがそこにあること自体は伝わる、という考え方です。 ここまでは検索すれば出てくる話です。 詰まったのはこの後でした。 装飾をそのまま移せなかった 無効なボタンは disabled:opacity-40 で薄くしていました。 これを aria-disabled の側にそのまま移そうとして、手が止まりました。 WCAG 2.2 の達成基準 1.4.11(非テキストのコントラスト)は、UI 部品の見た目に 3:1 を求めます。 ただし例外があって、操作できない部品は対象外になります。 opacity-40 で薄くしていたのは、この例外に入っている前提でした。 aria-disabled にすると、その前提が揺らぎます。 要素は生きていて、フォーカスも当たり、DOM 上は操作できる状態のままだからです。 ここは W3C でも決着していなかった 調べてもらったところ、この点は仕様として決まっていませんでした。 1.4.11 の Understanding 文書が例外として挙げているのは、HTML の disabled 属性が付いた部品です。 aria-disabled が同じ扱いになるかは書かれていません。 W3C の議論(discussions#3502・issues#869)でも、 disabled 属性 → 確実に例外の対象 aria-disabled="true" → セマンティックには無効だが、要素はフォーカス可能で、機能を止めるのは開発者の仕事。例外の定義に完全には当てはまらない可能性がある という形で分かれたままになっています。 だから「aria-disabled は例外に入らない」とは書けません。 書けるのは、寄りかかる根拠が弱くなったということだけです。 判断としては、寄りかからないほうを選びました。 透明度を下げるのをやめて、色そのものを落として 3:1 を満たす形にしています。 弱く見えることは変わらず、基準は満たす。 グレーゾーンを避けるほうが、後から説明しやすいと思いました。 トレードオフが1つ増えた aria-disabled にしたことで、無効なボタンにも Tab が止まるようになりました。 disabled のときはスキップされていた場所です。 WAI-ARIA がこれを推奨しているのは「押せないボタンがそこにあると分かるように」という理由なので、意図どおりではあります。 ただ、押せないボタンを何度も通過することにはなるので、手数は増えます。 カードが10枚あれば、無効なボタンは上下の端に2つです。 この数なら許容できると判断しましたが、無効な要素が大量に並ぶ画面なら別の答えになると思います。 送信を止める手当ては要らなかった aria-disabled は見た目と支援技術への通知を変えるだけで、クリックを止めません。 だから普通は、押されたときに処理しない手当てを別途書く必要があります。 今回は要りませんでした。 Server Action 側が、先頭のカードを上へ動かす要求を最初の行で弾いていたからです。 // 端のカードは動かせない。ボタンを無効にしていても、Server Action は直接叩ける if (index === 0) return これは「ボタンを無効にしてもサーバーは直接叩ける」前提で書いてあったものでした。 ボタンの見た目を変える話とは別に、入口の側で先に守ってあったことになります。 直す、で終わりではなかった この工程で分かったのは、アクセシビリティの修正が別のアクセシビリティ基準を呼び込むことがある、ということでした。 フォーカスの問題を直したら、コントラストの基準が顔を出す。 そのコントラストの基準は、例外の適用範囲が決まっていない領域だった。 1つ直すと1つ増えるので、どこで止めるかを自分で決める必要がありました。 止め方としては「仕様で決まっていないところには寄りかからない」を選んでいます。 基準を満たす側に倒しておけば、あとで解釈が固まっても作り直しになりません。 opacity を1行変えるだけの話なので、ここは安いほうを選べました。