描画順(スタッキング順)仕様

最終更新: 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

この不具合は仕様を先に読んでいれば実装前に防げた。以降、標準に基づく領域は 「規格 → 仕様書 → モジュール → 単体試験 → 実装」の順で進める。

用語

不変条件(最重要)

I-1. 祖先の背景は、その子孫より必ず先に描かれる。 子孫が祖先より前に描かれることは、z-index の値に関わらず起こらない。 今回の不具合はこの条件の違反そのもの。

I-2. 同一スタッキングコンテキスト内で z-index が同値なら、文書順で描く。

I-3. 判定できない場合は「後ろに回さない」側へ倒す。 描画順を誤って手前にしても重なりが変わるだけだが、 祖先の背景を子孫より後ろに回すと中身が消えるため被害が非対称。

Appendix E の段階(原典どおり・同一スタッキングコンテキスト内の描画順)

  1. ルート要素の背景(色 → 画像)をキャンバス全体に
  2. ブロック/テーブル要素の背景・画像・境界
  3. z-index が負の位置指定子孫(最も負のものから)
  4. フロートしていない、フロー内のブロックレベル子孫の背景と境界
  5. フロートしている子孫
  6. インラインスタッキングコンテキスト要素
  7. ブロックレベル置換要素、インライン内容、テキスト装飾
  8. z-index が auto または 0 の位置指定子孫
  9. z-index が 1 以上の位置指定子孫(小さいものから)
  10. アウトライン(先に描かれていなければ)

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 の節)。

【2026-07-28 訂正】I-1 は実効 z だけでは満たせない

当初ここに「祖先は文書順で必ず先に現れるため、実効 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 に分離完了。後述)。

【2026-10-13】段階 8(z auto/0 の位置指定子孫)を通常フローより手前へ

(実効 z, 深さ) だけでは、position 付きで z が auto/0 の要素も通常のブロックと同じ層になり、 文書順で先に来る固定配置の要素が、後続の背景付きブロックの下に隠れた (<div style="position:fixed"> → 背景付き <div> の最小ページで再現。2026-10-09 発見)。 各描画要素に is_positioned(自身または祖先が static 以外)を持たせ、実効 z が 0 の要素だけ 「位置指定層」(非位置指定=0、位置指定=1)を第 2 キーにした。段階 4〜7 の後に段階 8 が来る。

これらは既知の未対応として spec/TODO.md に残す。近似であることを実装の doc コメントにも明記し、「仕様どおり」と誤読されないようにすること。

【2026-10-18】入れ子のスタッキングコンテキスト

平坦な実効 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 はそれを含むコンテキストの中でだけ比べる。

【2026-10-27】CSS 規格に準拠したスタッキングコンテキスト生成条件の拡張

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 にて検証済み。

【2026-10-29】段階 10(アウトライン)の分離と全要素描画後の一括最前面描画

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 を新設・検証済み。

【2026-07-28 解決】テキストが 1 文字も出なかった真因

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: 有無も機械的に判定できた)。

単体試験で固定した契約