最終更新: 2026-10-29
対象実装: arm/v1/src/os_lib/web_engine/paint_order.rs
単体試験: arm/v1/tests/src/test_paint_order.rs
典拠: CSS 2.1 Appendix E “Elaborate description of Stacking Contexts” (https://www.w3.org/TR/CSS21/zindex.html)。 原典参照済み(2026-07-28、ユーザ許可のうえ上記 URL を参照)。 原典は 10 段階で記述されている(当初手元の知識で 7 段階と書いていたのは誤り。訂正済み)。
実サイト www.sugi-lab.net
の再現中、ヒーローセクションの中身が丸ごと消える不具合が出た。
原因は描画側が全要素を z-index
でフラットにソートしていたこと。実サイトの CSS に
section { position: relative; z-index: 5 }があり、<section class="hero" id="home"> は
z=5、その子孫(.hero-overlay / .hero-content /
.hero-title)は position: static なので z=0
だった。 フラットソートでは 0 < 5 で子孫が先に描かれ、直後に
section の背景が上から塗り潰す。
実測(draw() 内の読み返し):
[HERO][PIXELS] written=2724 clipped=0 out_of_bounds=0 readback_now=1655
[HERO][ENDOFDRAW] remaining=0
[HERO][OVERPAINT] id="home" z=5 rect=(0,0,924,529) bg=0xffff6b6b
この不具合は仕様を先に読んでいれば実装前に防げた。以降、標準に基づく領域は 「規格 → 仕様書 → モジュール → 単体試験 → 実装」の順で進める。
position が
static 以外かつ z-index が auto
以外の要素、 position: fixed / sticky、および
CSS 仕様で定められたプロパティ
(opacity < 1、transform、filter、backdrop-filter、isolation: isolate、
contain: paint | layout | strict | content
等)を持つ要素が生成する(2026-10-27 対応完了)。I-1. 祖先の背景は、その子孫より必ず先に描かれる。
子孫が祖先より前に描かれることは、z-index
の値に関わらず起こらない。 今回の不具合はこの条件の違反そのもの。
I-2. 同一スタッキングコンテキスト内で z-index
が同値なら、文書順で描く。
I-3. 判定できない場合は「後ろに回さない」側へ倒す。 描画順を誤って手前にしても重なりが変わるだけだが、 祖先の背景を子孫より後ろに回すと中身が消えるため被害が非対称。
z-index が負の位置指定子孫(最も負のものから)z-index が auto または 0
の位置指定子孫z-index が 1 以上の位置指定子孫(小さいものから)I-1 の根拠: 背景は段階 1・2・4 に置かれ、子孫の内容は段階 3〜9 で描かれる。 原典に対する確認結果も明示的に「子孫が祖先の背景より前に描かれることはない」。
現状の paint_order.rs は段階 1〜7 を完全実装していない。
描画側が保持するのは平坦な要素列であり、段階分けに必要な
「フロート/ブロック/インライン」の区別と子コンテキストの入れ子情報を持たないため。
現在の近似(2026-10-13 以降): 各要素の実効 z を
max(自身の z, 祖先の実効 z) とし、
paint_sort_key =
(実効 z, 位置指定層, ツリー深さ)
の昇順で安定ソートする (経緯は下の 2026-07-28 訂正と 2026-10-13
の節)。
当初ここに「祖先は文書順で必ず先に現れるため、実効 z が同値なら安定ソートが 祖先→子孫の順を保つ」と書いていたが、この前提は偽だった。
根拠(実機ログ 2 行から論理的に確定。追加計測は不要だった):
[HERO][OVERPAINT] id="home" z=5 ← section#home(ヒーローテキストの祖先)
[HERO][OVERPAINT] id="_ta_49" z=5 ← .hero-overlay(同じく祖先)
.hero-overlay は position:static で own_z=0
なのに z=5 になっている =実効 z
の伝播は正しく動いている。それでもこの 2 つの祖先が
テキストより後に描かれている。よって原因は z
ではなく要素列の並び順であり、
flatten_layout は祖先より先に子孫を
self.elements へ積む経路を持つ。 z
が同値だと安定ソートはその誤った順序をそのまま保存してしまう。
正しい方針: emit 順に依存しない。各要素にツリー深さ
paint_depth を持たせ、
(実効 z, paint_depth)
の昇順でソートする。同一 z 内では浅い要素(祖先)が
必ず先になり、I-1 が emit 順と無関係に構造的に保証される。 -
満たさないもの: 段階 3(負 z
の位置指定子孫)を親背景より後ろへ回せない。 段階 4/5/6/7
の区別(ブロック背景・フロート・インライン・テキストの前後関係)も未実装。
(段階 10 アウトラインは 2026-10-29 に分離完了。後述)。
(実効 z, 深さ) だけでは、position 付きで z
が auto/0 の要素も通常のブロックと同じ層になり、
文書順で先に来る固定配置の要素が、後続の背景付きブロックの下に隠れた
(<div style="position:fixed"> → 背景付き
<div> の最小ページで再現。2026-10-09 発見)。
各描画要素に is_positioned(自身または祖先が static
以外)を持たせ、実効 z が 0 の要素だけ
「位置指定層」(非位置指定=0、位置指定=1)を第 2 キーにした。段階 4〜7
の後に段階 8 が来る。
paint_order::paint_sort_key(effective_z, is_positioned, depth)、
RenderElement::is_positioned(web_engine/layout.rs
で祖先から継承)。tests/src/test_paint_order.rs(先行する fixed
要素が後続の通常ブロックより手前 等)。これらは既知の未対応として spec/TODO.md
に残す。近似であることを実装の doc
コメントにも明記し、「仕様どおり」と誤読されないようにすること。
平坦な実効 z では、スタッキングコンテキストの中の大きい
z-index が外へ漏れる。YouTube 埋め込みで
#movie_player{position:relative;z-index:0} の中の
.html5-video-container{z-index:10}(<video>)が、
player の外にある後続の操作部(#player-controls、z
auto)より手前に描かれ、操作部が一切見えなかった。 原典どおり
z-index
はそれを含むコンテキストの中でだけ比べる。
stack_path と、要素を生んだ箱の文書順
paint_seq を持たせる(web_engine/layout.rs
の子ループの後処理で決める)。(実効 z, 位置指定層, 文書順, 深さ)(paint_order::stack_step)。描画順は
「段の列 +
自分の段」の辞書順(paint_order::paint_cmp)。コンテキストを作る要素の段は
子孫の列の接頭辞なので、その要素は自分の中身より必ず先(I-1)。z-index
が数値」「position:fixed/sticky」。
(opacity < 1・transform 等は 2026-10-27
に追加対応。後述)。paint_seq)が先、同値(祖先が子孫より後に積まれた場合)は深さの浅い方が先。
2026-10-13
の節の「深さの違う兄弟の子孫どうしの前後が文書順とずれる」近似はこれで解消した。tests/src/test_paint_order.rs(コンテキスト内の大きい z
が外の後続を越えない 等 4 件)。 www.sugi-lab.net
のヒーローは変更前と同じ見た目(QEMU で撮影比較)。CSS 仕様(CSS Color 3, CSS Transforms 1, Filter Effects 1, CSS
Compositing 1, CSS Containment 1)に基づき、以下のいずれかを満たす要素は
position や z-index
の値に関わらず常にスタッキングコンテキストを形成するよう拡張された。 -
opacity < 1.0 - transform が
none 以外 - filter が none 以外 -
backdrop-filter が none 以外 -
isolation: isolate -
contain: paint | layout | strict | content -
mix-blend-mode が normal 以外 -
mask / mask-image / clip-path
が指定されている要素
これにより、半透明要素や変形アニメーション要素、コンテナ要素の子孫要素の描画順が親スタッキングコンテキスト内に正しく包含され、外側要素への不当な漏れ出しや前後関係の崩れが解消された。
- 実装: web_engine/layout.rs の
creates_stacking_context 判定拡張。 - 試験:
arm/v1/tests/src/test_paint_order.rs にて検証済み。
CSS 2.1 Appendix E §E.2 (“Stage 10: Finally, the outlines of the
element and all its descendants”) に基づき、従来ボーダー描画直後(Stage
4)にインライン描画されていた draw_outline
を独立した描画パスへ分離。 - 要素描画ループ中に
pending_outlines 保留リストへ
(ox0, oy0, ox1, oy1, width, offset, color, style, clip_rect)
を蓄積。 -
全要素(背景、テキスト、フロート、位置指定子孫すべて)の描画が完了した直後(スクロールバー等の
OS UI 描画直前)に、段階 10
として蓄積されたアウトラインを最前面に一括描画。 -
後続の兄弟要素や子孫要素の背景・テキストによってアウトラインが塗り潰されることがなくなり、規格準拠の重なり順序を完全保証。
- 実装:
arm/v1/src/os_lib/web_engine/paint_order.rs(PaintStage::Outline = 10)、arm/v1/src/os_lib/web_engine/render.rs。
- 試験: arm/v1/tests/src/test_paint_order.rs にて
test_stage_10_outline_paints_after_all_stages
を新設・検証済み。
RenderElement の生成箇所は layout.rs に
12 箇所あり、そのうち 5 箇所(4081 / 4179 /
4223 / 4386 / 4503 付近)が z_index を設定せず
..Default::default() に頼っていた。既定値は
z=0・depth=0 で、
これは描画ソートで必ず先頭に来るため、あらゆる背景に塗り潰される。
テキスト系要素がこの経路で作られていたため、1
文字も画面に残らなかった。
全生成箇所に祖先の z_index / paint_depth
を継承させたところ、
ヒーロー見出し・サブテキスト・ボタンラベル・ナビゲーションがすべて表示された。
設定漏れが 5 箇所も生まれたこと自体が問題。RenderElement
を直接構築させず、 祖先の z/depth
を必ず引数で受け取るコンストラクタ経由に一本化すべき。
..Default::default()
は「新しいフィールドを足しても既存箇所がコンパイルエラーに
ならない」という性質があり、今回のような漏れを静かに量産する。
spec/TODO.md に恒久対策として記載。
原因特定の過程で、ログの z=5(.hero-overlay
が伝播できている)を根拠に 「伝播は動いている」と 2
回結論したが、いずれも誤りだった。実際は
「一部の生成経路だけ動いている」。推論を 1
本に絞らず、生成箇所を全列挙して
網羅的に確認するのが正しい手順だった(grep -c "RenderElement {"
で 12 箇所と 即座に分かり、各箇所の z_index:
有無も機械的に判定できた)。
section{position:relative;z-index:5} の static
な子孫が祖先より前に描かれないこと (今回の回帰そのもの).hero > .hero-overlay > .hero-content > .hero-title)で
実効 z が祖先まで引き上がることz-index
を持つ子孫は祖先より手前に来ること.navbar{z-index:1000}
がセクション(5)の子孫より手前を維持することErr
を返すこと