Astroの図版を自分のサイズで描かせ、タップで拡大できるようにする — 固有幅・ネイティブダイアログ・@2x

Astroでwidth:100%が256pxのスクリーンショットをぼかしていた理由と、ネイティブダイアログでスマートフォンでも図版が読めるようになるまで。設計を正したのは7つの計測でした。

黒いカードにピンクの炎を伴う白のAstroロケットロゴとロゴタイプ
目次

はじめに

このサイト自身の目次サイドバーのスクリーンショットを記事に入れたところ、ひどい見た目で返ってきました。サイドバーは細長い UI ですが、記事の中では本文カラムいっぱいの幅まで引き伸ばされ、中の文字がにじむほどぼやけ、他の内容を画面外へ押し出すほど縦に伸びていました。それを眺めているうちに、もう半分の問題にも気づきます。どの図版も大きくする手段がないのです。読者がもっとよく見たいと思っても、できるのはページ全体をピンチズームすることだけでした。

そのスクリーンショットはリリース 1 本分のあいだ公開され続け、誰からも指摘されていませんでした。撮影そのものにも問題はありません。ぼやけの原因は、サイト上のすべての図版に適用されていた 1 つの CSS 宣言でした。そして被害が出るのは、細い図版だけだったのです。

ぼやけの修正は、結果的に小さい方の話でした。より大きな発見は同じコンポーネントの反対側にありました。引き伸ばされたことが一度もなく、そのため問題なく見えていた図版こそ、誰にも読めていなかったのです。本記事ではその両方と、途中で明らかに正しいと思えたのに計測すると間違っていた 7 つの設計判断を扱います。

1 つの宣言がすべての図版をカラム幅まで引き伸ばしていた

このサイトの図版はすべて 1 つのコンポーネントを通ります。そのコンポーネントには、包んだ相手が何であれ適用される規則が 1 つだけありました。

src/components/article/Figure.astro
.frame :global(svg),
.frame :global(img) {
display: block;
width: 100%;
height: auto;
}

蓄積された記事の大半では、これは表に出ません。イラストの幅は 1,100 から 1,774 ピクセルで、本文カラムは 662 ピクセルなので、この規則は縮小しか起こさず、それには何のコストもありません。問題のサイドバーのスクリーンショットは幅 256 ピクセル・高さ 836 ピクセルだったため、同じ規則が 2.48 倍に引き伸ばし、662 × 2,161 にしてしまいました。

興味深いのは、ブラウザに与えられていた材料の方です。Astro は元ファイルから srcset を生成しますが、256 ピクセルの元ファイルからは候補が 1 つしか出てきません。

<img src="/_astro/sidebar-rail.DjnEbD0x_Z25K3Dc.webp"
srcset="/_astro/sidebar-rail.DjnEbD0x_Z25K3Dc.webp 256w"
sizes="(min-width: 256px) 256px, 100vw"
width="256" height="836">

つまりブラウザは 256 ピクセル分の画像をダウンロードし、CSS から 662 ピクセル分のレイアウトを埋めるよう指示され、それ以上大きいものを取りに行く先を持っていなかったわけです。私が見ていたノート PC はデバイスピクセル比が 2 だったので、実際の必要量は 1,324 デバイスピクセル、それを埋めていた実データは 256 ピクセルでした。

256px のスクリーンショットを 662px で描画した場合と、元の幅のまま描画した場合662px の本文カラムを基準にした 2 本の横棒。上の棒は width: 100% とラベルされ、カラム幅いっぱいの 662px に達し、2.48 倍の拡大で 1,324 デバイスピクセルを必要とするが画像は 256 しか持たないと示されている。下の棒は width: auto とラベルされ、256px で止まり、拡大なしで必要なのは 512 デバイスピクセル、同じく 256 を持つ。本文カラム 662pxwidth: 100%2.48 倍に拡大662px で描画必要 1,324 デバイスピクセル / 実際 256width: auto拡大なし256px で描画必要 512 デバイスピクセル / 実際 256画像の幅は 256px。ブラウザに無い画素を要求しているのは一方だけ。
同じファイルを 2 つの規則で描いた場合。棒は描画幅で、その下の一行が、くっきり見えるかどうかを決める数字です。

リポジトリの中にこれを見張っているものは何もありませんでした。ロケール間チェックは、英語で参照されている図版が日本語と繁体字中国語でも参照されていることを確かめますが、ファイル自体は見ません。フィットチェックは各図版をブラウザで描画してラベルが枠からはみ出していないかを測りますが、対象は SVG コンポーネントだけです。翻訳されたテキストを持つのがそちらだからです。ラスターには専用のチェックがなく、引き伸ばされたスクリーンショットと本番の間に立っていたのは、誰かが気づくかどうかだけでした。

サイズを宣言せず、導出する

修正はバグより小さいものでした。Astro は処理したすべての画像に width と height 属性を出力します。ビルド時にファイルの実寸を知っているからです。CSS の幅を auto にすれば、ブラウザはその数字を使います。

src/components/article/Figure.astro
.frame :global(img) {
display: block;
margin-inline: auto;
width: auto;
max-width: 100%;
max-height: 56rem;
height: auto;
}

もう一つの案はコンポーネントに width プロパティを持たせることでしたが、私はそれを見送りました。このブログは記事ごとに 1 つのフォルダを持ち、その中に 3 つのロケールファイルが入ります。つまりマークアップ側の幅は 3 回書かれ、素材と手作業で同期を取り続けなければならない数字になり、整合性チェッカーにもそれを取り締まる規則を足す必要が出てきます。width: auto ならサイズが記録される場所はファイルだけになり、ファイルを差し替えればレイアウトもついてきます。

最初に間違えたのは高さの上限でした。エージェントの初期値は min(48rem, 85svh) で、図版がウィンドウより高くなるべきではないという理屈でした。高さ 724 ピクセルのブラウザウィンドウで測ると、幅 256 のスクリーンショットは 188 × 615 に抑え込まれます。リサンプリングを直したばかりの画像を、もう一度リサンプリングしたわけです。可視区域に追従する上限は、「決して引き伸ばさない」を「常にリサイズする」に戻してしまいます。現在の値は一律 56rem で、蓄積された記事のうち最も高い 836 ピクセルの図版を素通しし、それでいて異常な書き出しは捕まえます。

最後の一点は見た目の問題です。幅 224 ピクセルのスクリーンショットが 662 ピクセルの枠線付きの箱の中央に置かれていると、それ自体が間違いに見えます。そこで枠はラスターに合わせて縮みます。コンポーネントは中身をスロット経由で受け取るため、包んだ相手が写真なのか図版なのかをサーバー側では判断できません。判定は CSS で行います。

src/components/article/Figure.astro
.figure:has(.frame img) .frame {
width: fit-content;
max-width: 100%;
margin-inline: auto;
}

@2x — width: auto には「密」と「大」の区別がつかないから

固有幅からサイズを決めても、1 つだけ解決しない問題が残ります。2 倍密度で 224 ピクセルとして表示されるつもりの 448 ピクセルのファイルは、CSS から見れば、448 ピクセルで表示されたい 448 ピクセルの絵と区別がつきません。合図はどこか別の場所から来る必要があり、いちばん安上がりな置き場所はファイル名です。<name>@2x.webp という名前のラスターは意図した表示サイズの 2 倍で保存されており、コンポーネントが zoom: 0.5 で半分にします。

この規則の中の 2 点は、推論ではなく計測で見つかりました。そしてどちらも無言で壊れる種類のものです。

セレクタは 2 通りにマッチする必要があります。astro build はファイル名の語幹をそのままアセット名に書き込み、/_astro/sidebar-rail@2x.HASH.webp を生成します。しかし開発サーバーは違います。パスをクエリパラメータに載せたエンドポイント経由で画像を配信するため、@ はパーセントエンコードされて %402x になります。生の形だけにマッチする規則は本番では動き、ローカルでは何もしません。取り得る中で最悪の組み合わせです。実際に最初はそう書かれていて、それが見つかったのは、ブラウザ上の数字が動かなかったからでした。

2 つの上限は zoom の下で異なる振る舞いをします。片方だけが 2 倍にされているのはそのためです。

宣言実際に効く位置
max-height: 56rem画面上の 448 ピクセル。絶対長は要素自身の半分になったピクセルで解釈されるため、上限は書いてある値の半分の位置に来ます。
max-width: 100%枠の実寸幅。パーセントは包含ブロックに対して解決され、そちらは zoom されていないため、書いてあるとおりの意味になります。

最初の試みは両方を 2 倍にしていました。両者は同じようには振る舞わず、その代償として画像が狭めた枠から 70 ピクセルはみ出しました。規則が動くようになってから、元のスクリーンショットを 2 倍解像度で撮り直してもらいました。現在は 448 × 1,672 で、224 × 836 で描画され、必要な 448 デバイスピクセルを 448 の実データで埋めています。

問題なく見えていた図版こそ読めていなかった

ここまでがサイズの話で、深刻な影響を受けたファイルはちょうど 1 つでした。もう半分の作業は、エージェントが「実際に縮小されている図版にだけ拡大の操作を出す」という案を出したところから始まります。すでに原寸で表示されているものを拡大しても読者には何も約束できない、という理屈でした。

私はそれを却下しました。図版は原寸で表示されていても、中の文字が小さすぎて読めないことがあります。そしてそれはスクリーンショットと同じくらい、いやそれ以上に図版に当てはまります。決め手になった計算を示しておく価値があります。記事の残りが乗っている数字だからです。

このリポジトリの図版はすべて 800 単位の viewBox 上で作図され、他のすべてと同じ 662 ピクセルの枠に描画されます。デスクトップでの倍率は 0.795 です。390 ピクセルのスマートフォンでは枠の幅は 324 ピクセルになり、倍率は 0.405 になります。実際に使われているラベルサイズを通してみると、こうなります。

作図時の font-size使用箇所デスクトップ(0.795 倍)スマートフォン(0.405 倍)
9px207.2 CSSピクセル3.6 CSSピクセル
10px1368.0 CSSピクセル4.1 CSSピクセル
11px1358.7 CSSピクセル4.5 CSSピクセル
13px7810.3 CSSピクセル5.3 CSSピクセル

図版はこのサイトで最も丁寧に作られているものですが、スマートフォンではそのラベルが 3.5 から 5 ピクセルほどの高さで描かれています。「縮小されているか」を条件にした判定は、まさにその図版から操作を隠していたはずです。図版は縮小されないからです。図版は拡大縮小されるのであって、読めなくしているのはその拡大縮小の方です。

そこで拡大の挙動はすべてに適用されることになりました。ラスターも図版も、どの可視区域でも開きます。この判断こそが、ぼやけたスクリーンショット 1 枚の修正を、サイト上の 86 個すべての図版に、3 言語すべてで及ぶ変更に変えたものでした。

同じ図版のラベルを、作図時・本文カラム・スマートフォン・ダイアログで測った比較1 つの 800 単位の図版を 4 通りの描画で示した 4 枚のカード。作図時の基準ラベルは 10 単位と 11 単位。662px の本文カラムでは倍率 0.795 となり 8.0 と 8.7 CSSピクセルで描画される。324px のスマートフォンのカラムでは倍率 0.405 となり 4.1 と 4.5 CSSピクセルまで落ち、これが問題として示されている。ダイアログでは 800px、倍率 1.0 で描画され、ラベルは 10 と 11 CSSピクセルに戻る。作図時800 単位の viewBox基準10px のラベル1011px のラベル11CSSピクセル本文カラム662px×0.79510px のラベル8.011px のラベル8.7CSSピクセルスマートフォン324px×0.40510px のラベル4.111px のラベル4.5CSSピクセルダイアログ800px×1.00010px のラベル1011px のラベル11CSSピクセル図版そのものは何も変わっていない。変わったのは注ぎ込まれた器の方だけ。
同じ図版が求められる各サイズで並んでいます。いちばん右のカードが「開くと戻ってくる状態」で、記事の残りはそこへ辿り着くまでの話です。

ダイアログに必要だったのは 1 つではなく 2 つの規則

パターン自体はサイトにすでにありました。検索モーダルは showModal() で開くネイティブの <dialog> で、フォーカスの扱いも Escape も ::backdrop も、手で書かずに手に入ります。そしてほとんどの画像ライトボックスにはない性質が 1 つあります。位置指定のオーバーレイではなくブラウザのトップレイヤーに置かれるため、ピンチズームがその中でも効き続けます。これは字面以上に効きます。上限が固定された拡大鏡と、読者がさらに押し進められる拡大鏡の違いだからです。

最初の版は縦横ともウィンドウに合わせていました。ライトボックスがやることそのものです。256 × 836 のスクリーンショットを開くと 202 × 661 で返ってきました。記事内ですでに得ていた 256 × 836 より小さい値です。縦長の画像を横長のウィンドウに合わせれば、それは縮小になります。こうして最初の拡大鏡は、ものを小さくしていました。

2 番目の版はラスターについてはこれを直しましたが、図版については依然として間違っていました。ラスターはファイルの実寸幅を上限とし、ウィンドウより高ければスクロールするようになり、これは正解です。図版には止まるべき原寸がないためウィンドウに合わせられ、幅 500 ピクセルのブラウザでは倍率が 1.03 倍になりました。図版はすでにカラムを埋めていたので、ウィンドウに合わせても何も得られません。ダイアログは、自分が解決するために存在していた問題を再現していたのです。

2 つの試みが見落としていたのは、2 つの媒体が正反対のものを求めているという点です。ラスターはピクセル数が決まっており、それを超えて拡大すれば細部は描き足しになります。図版はベクターで、上限はなく、意味のある下限があります。それが作図された viewBox です。800 単位を下回れば、そのラベルはそもそも記事内で読めなかった縮小版に戻ります。そこで規則は分けられました。

src/components/article/Figure.astro
/* A diagram fits the window, but never draws below the grid it was authored on. */
.figzoom-frame[data-figzoom-media='vector'] .figzoom-stage {
min-width: calc(var(--figzoom-vbw, 800) * 1px);
}
/* A raster shows every pixel it has, and pans if it is taller than the window. */
.figzoom-frame[data-figzoom-media='raster'] {
width: min(var(--figzoom-cap, 95vw), 95vw);
}
ダイアログが媒体ごとに適用する 2 つのサイズ規則と、却下された 1 つの規則2 枚のカード。左はベクターである図版を扱う。ダイアログはウィンドウに合わせて拡大するが、800 単位の viewBox を下回っては描画しないため、スマートフォンでは 324px から 800px になる。右はラスターであるスクリーンショットを扱う。ダイアログはファイル幅を上限とし、ウィンドウより縦に長ければパンさせるので、256px のファイルは 256px のままとなる。下の帯は却下された 1 つの規則を記録している。縦横ともウィンドウに合わせる方式で、256×836 のスクリーンショットが 202×661 で返った。図版(ベクター)ウィンドウに合わせる。下限は viewBox上限なし。拡大しても劣化しない800px を下回らないスマートフォンで 324px → 800pxスクリーンショット(ラスター)ファイル幅が上限。収まらなければパン上限あり。超えれば描き足しになるファイル幅を超えない256px → 256px、スクロールで表示両方に 1 つのルール : 縦横ともウィンドウに合わせる256×836 のスクリーンショットが 202×661 で返り、記事内の表示より小さくなった
2 つの媒体に 2 つの上限。下の帯は最初に試した 1 つの規則と、それが返してきた数字です。

1 単位 1 ピクセルなら font-size: 10 のラベルは 10 CSSピクセルで描かれ、枠は縮む代わりにパンします。記事がすでに最も横長の図版に対して選んでいるのと同じ取引です。ビルド済みのサイトで測ると、図版はデスクトップでインラインの 662 ピクセルからダイアログ内の 1,342 ピクセルへ、スマートフォンでは 324 から 800 へ変わります。記事内で高さ 4.5 CSSピクセルだった font-size: 11 のラベルは、ダイアログ内では 11 CSSピクセルになり、そこからピンチズームでさらに拡大しても、にじむことはありません。

すべての図版から説明を消していた button

この時点で、作業は型チェック、ロケール間チェック、本番ビルド、フィット計測をすべて通過していました。その後ブランチに対してレビューを走らせたところ、2 つの欠陥が見つかります。どちらもビルドが捕まえる種類のものではありませんでした。

1 つ目は、エージェントがトリガーに選んだマークアップの中にありました。図版全体を <button> で包むのはクリック可能にする自然な方法ですが、静かに破壊的です。ARIA は button の子要素を presentational と定めています。中にあるものはすべてアクセシビリティツリーから取り除かれ、そしてツリーはスクリーンリーダーが持つ唯一の手がかりです。

ここで取り除かれたものは装飾ではありません。図版はいずれも role="img" と、タイトルと説明を指す aria-labelledby を持ち、ラスターはいずれも alt を持ちます。これらは翻訳パイプラインが 3 言語で維持している文字列であり、多くの場合、図版に付随するテキストの中で最も長く、最も丁寧に書かれたものです。button で包まれた結果それらはすべて消え、スクリーンリーダーに残ったのは button 上の 2 語だけになりました。サイト上のすべての図版で、です。

同じ図版のアクセシビリティツリー。トリガーが図版を囲むのをやめる前と後同じ図版のアクセシビリティツリーを 2 面で示す。左はトリガーが図版全体を囲む button である場合で、ツリーには button「図版を拡大」という 1 ノードしか残らない。role="img" と aria-labelledby を持つ svg と、alt を持つ img は淡い破線の枠で示され、ツリーから落ちたことが示されている。button は子要素を presentational として扱うためである。その結果、86 個すべての図版で 3 言語とも、説明の代わりに 2 語だけが残る。右はトリガーが図版の横のバッジである場合で、ツリーには図版の説明を持つ image ノードと、「図版を拡大」という名前の button ノードの両方が残る。図版全体を button で囲むbutton「図版を拡大」svg role="img" aria-labelledbyimg alt="…"図版の横にバッジを置くimage「サイドバーの目次…」button「図版を拡大」86 個すべての図版で、3 言語とも、残るのは 2 語だけ図版は自身の説明を保ち、操作にも名前がある
淡く表示された行も DOM には残っており、翻訳もされています。ただ公開されていないだけです。

修正は「包むのをやめる」ことです。トリガーは今、図版を囲む button ではなく、枠の中で図版の横に置かれた虫めがねのバッジになっています。これで画像は自身のセマンティクスを保ち、バッジが名前とポップアップの告知を担います。ポインタ操作の読者のために枠のどこをクリックしてもダイアログは開きますが、その経路に依存しているものはありません。あとからアクセシビリティツリーを読み直すと、各図版は完全な説明を持つ image ノードと、独立した button「図版を拡大」 の両方を報告します。最初からそうあるべきだった形です。

幅ではなかった幅

2 つ目の欠陥は、すでに一度目にして、説明をつけて片付けられていた数字でした。

ダイアログはラスターを実寸幅からサイズ決めしており、コードは naturalWidth を読んでいました。レスポンシブ画像においてこのプロパティは密度で補正されます。Astro が出力する幅記述子の場合、密度は候補の幅を sizes の解決結果で割ったものになるため、返ってくる値はファイルの幅ではなくレイアウト上の幅です。390 ピクセルの可視区域で測ると、448 ピクセルのファイルが naturalWidth: 389 を返しました。

これは上限を figure がすでに占めていたサイズあたりまで潰し、この機能が存在する理由である狭い画面でこそ、拡大を最も強く無効化していました。正しい値は width 属性で、しかもそれはフォールバックとしてすでにコード内にありました。2 つの順序が逆だっただけです。

居心地が悪いのは、この 389 が以前の計測パスですでに現れていて、エージェントがそれを開発サーバーの癖として片付けていたことです。癖ではありませんでした。説明をつけて片付けられた計測は、一度も取られなかった計測と同じだけの価値しかありません。

数字が語ったこと

以下の数値はすべて、開発サーバーではなく本番ビルドに対して計測しました。開発用の画像エンドポイントは異なる候補を返し、自信のある誤った値を出すからです。

変更前変更後
スクリーンショットの描画サイズ662 × 2,161224 × 836
必要なデバイスピクセル1,324448
利用できるデバイスピクセル256448
図版・デスクトップ662pxダイアログ内 1,342px
図版・スマートフォン324pxダイアログ内 800px
font-size: 11 のラベル・スマートフォン4.5 CSSピクセル11 CSSピクセル

スクリーンショットは、求められたピクセルの 5 分の 1 しか埋めていない状態から、すべてを埋める状態になりました。スマートフォンでは図版は 2.47 倍に拡大され、しかもベクターの写真ではなくベクターそのものなので、読者がそこからさらにピンチズームしても、どの倍率でもくっきりしたままです。

まとめ

元のバグは、たまたま手元にあったすべての図版に対しては正しく、その前提に当てはまらなかった最初の 1 枚に対しては間違っていた、1 つの宣言でした。それを width: auto に置き換えることで、判断の場所はファイルへ移り、3 つのロケールファイルを編集する代わりに、ものを差し替えれば変わるようになりました。

より大きな発見は同じコンポーネントの反対側にあり、それが検討されたのは最初の提案が却下されたからにすぎません。86 個の図版がスマートフォンで 4 ピクセル前後の高さのラベルのまま公開され続けていて、そのどれもが問題として認識されていませんでした。何も変わっておらず、何も測っていなかったからです。ぼやけたスクリーンショットは自分から名乗り出ます。ただ小さすぎるだけの図版は、名乗り出ません。

残す価値があるのはどちらの修正でもありません。この作業における 7 つの設計判断は、いずれももっともらしく、擁護でき、そして間違っていました。そのすべてを捕まえたのは、より深く考えることではなく、数字を当てることでした。可視区域に追従する高さ上限、縦横ともウィンドウに合わせたダイアログ、3 パーセントしか大きくならなかった図版、ローカルでは効いていなかったセレクタ、同じ振る舞いだと決めつけた 2 つの上限、アクセシビリティツリーを消した button、そして誤った幅を返していたプロパティ。どれも間違っているようには見えませんでした。とくに高さの上限は、ブラウザが 188 と答えるその瞬間まで、明らかに正しく読めていました。

グリーンのビルドはそのどれも報告しませんでした。型チェック、ロケール間の整合性、本番ビルド、フィット計測はすべて問題なしで返ってきていて、その裏でアクセシビリティツリーは空になり、拡大鏡は拡大対象より小さい画像を返していたのです。

参考リンク

この記事をシェア