Files
shturman/docs/specs/plans/06-v0.2-boot-pipeline.md
T
kk0t9 e841c082b3 docs(v0.2): план реализации boot-конвейера (План 6)
P6.1 общий рендер-хелпер shturman-render (рефактор из shell) →
P6.2 shturman-splash (Stage 0) → P6.3 фазовые systemd-таргеты + splash/warmup +
зонтик → P6.4 justfile/lima/E2E-блок Stage 0/1/2 → P6.5 verify в Lima + acceptance.
TDD-шаги с полным кодом, без плейсхолдеров. Self-review: покрытие спеки полное.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
2026-06-24 19:57:36 +03:00

525 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План 6 — v0.2 Boot-конвейер (Stage 0/1/2 + splash)
> REQUIRED SUB-SKILL: `executing-plans` (или `subagent-driven-development`) + **TDD**. Спека: `docs/specs/v0.2-boot-pipeline.md`.
> Шаги — чекбоксы `- [ ]`. Часть «verify в Lima» (P6.5) — тяжёлая (vm-reset + e2e), но VM уже поднята.
**Goal:** превратить плоский `shturman.target` (v0.1) в фазовый конвейер: Stage 0 (splash) → Stage 1 (ядро+кадр) →
Stage 2 (warmup), с мгновенным splash до первого кадра и деферредом фона после.
**Architecture:** общий headless-render хелпер (Slint software-renderer → PNG) выделяется из `shturman-shell` в
крейт `shturman-render`; новый `shturman-splash` его использует. systemd: зонтик `shturman.target` тянет три
под-таргета; splash `Before=shell`, warmup `After=shell`. Новой D-Bus-поверхности нет.
**Tech Stack:** Rust, Slint 1.16 (software-renderer + `png`), systemd (targets/oneshot), Lima VM, bash E2E.
---
## File Structure
- **Create** `crates/apps/shturman-render/``Cargo.toml`, `src/lib.rs` (хелпер `render_to_png`), `tests/render.rs`.
- **Create** `crates/apps/shturman-splash/``Cargo.toml`, `src/lib.rs` (Slint splash + `render_splash`), `src/main.rs` (CLI), `tests/screenshot.rs`.
- **Modify** `crates/apps/shturman-shell/src/lib.rs` — использовать `shturman_render::render_to_png` (убрать своё плумбинг-дублирование), `Cargo.toml` (+ dep `shturman-render`).
- **Modify** `Cargo.toml` (workspace) — добавить два крейта в `members`.
- **Create** `systemd/`: `shturman-stage0.target`, `shturman-stage1.target`, `shturman-stage2.target`, `shturman-splash.service`, `shturman-stage2-warmup.service`, `tmpfiles-shturman.conf`.
- **Modify** `systemd/shturman.target` (→ зонтик), `shturman-{firstboot,machineid,power,settings,shell}.service` (`WantedBy=``shturman-stage1.target`), `shturman-shell.service` (убрать `RuntimeDirectory`, `/run/shturman` даёт tmpfiles).
- **Modify** `justfile` (+ `splash-frame`), `lima/shturman.yaml` (разложить новые юниты + tmpfiles), `tests/e2e/run.sh` (+ блок Stage 0/1/2 + install splash-бинаря).
---
## P6.1: крейт `shturman-render` — общий headless-рендер (рефактор из shell)
**Files:** Create `crates/apps/shturman-render/{Cargo.toml,src/lib.rs,tests/render.rs}`; Modify workspace `Cargo.toml`, `crates/apps/shturman-shell/{Cargo.toml,src/lib.rs}`.
- [ ] **Шаг 1 — падающий тест** `crates/apps/shturman-render/tests/render.rs`:
```rust
use shturman_render::render_to_png;
slint::slint! {
export component Probe inherits Window {
width: 64px; height: 48px; background: #101418;
Rectangle { background: #ffffff; width: 20px; height: 20px; }
}
}
#[test]
fn renders_component_to_nonempty_png() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("probe.png");
render_to_png(|| Ok(Probe::new()?), 64, 48, &path).expect("render");
let dec = png::Decoder::new(std::fs::File::open(&path).unwrap());
let mut r = dec.read_info().unwrap();
assert_eq!((r.info().width, r.info().height), (64, 48));
let mut buf = vec![0u8; r.output_buffer_size()];
let info = r.next_frame(&mut buf).unwrap();
let px = &buf[..info.buffer_size()];
assert!(px.iter().any(|&b| b != px[0]), "кадр одноцветный");
}
```
- [ ] **Шаг 2 — `Cargo.toml` крейта** (`crates/apps/shturman-render/Cargo.toml`):
```toml
[package]
name = "shturman-render"
version = "0.0.0"
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
slint.workspace = true
png = "0.17"
[dev-dependencies]
tempfile.workspace = true
```
Добавить `"crates/apps/shturman-render"` в `members` корневого `Cargo.toml`.
- [ ] **Шаг 3 — прогнать тест, убедиться что НЕ компилируется/падает.** Run: `cargo test -p shturman-render`. Expected: FAIL (нет `render_to_png`).
- [ ] **Шаг 4 — реализация** `crates/apps/shturman-render/src/lib.rs` (вынести из текущего `shturman-shell/src/lib.rs`):
```rust
//! Headless software-render Slint-компонента в PNG (без дисплея/композитора).
//! Общий для shturman-shell (первый кадр) и shturman-splash (Stage 0). Спека v0.2 §4.1.
use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType};
use slint::platform::{Platform, PlatformError, WindowAdapter};
use slint::ComponentHandle;
use std::path::Path;
use std::rc::Rc;
use std::sync::Once;
thread_local! {
static WINDOW: Rc<MinimalSoftwareWindow> =
MinimalSoftwareWindow::new(RepaintBufferType::ReusedBuffer);
}
struct SwPlatform;
impl Platform for SwPlatform {
fn create_window_adapter(&self) -> Result<Rc<dyn WindowAdapter>, PlatformError> {
Ok(WINDOW.with(|w| w.clone()))
}
}
fn ensure_platform() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = slint::platform::set_platform(Box::new(SwPlatform));
});
}
/// Построить компонент (ВНУТРИ — после `set_platform`) и отрендерить его кадр в PNG.
/// `build` зовётся после установки software-platform (порядок обязателен для Slint).
pub fn render_to_png<C: ComponentHandle>(
build: impl FnOnce() -> anyhow::Result<C>,
w: u32,
h: u32,
path: &Path,
) -> anyhow::Result<()> {
ensure_platform();
let ui = build()?;
let window = WINDOW.with(|x| x.clone());
window.set_size(slint::PhysicalSize::new(w, h));
ui.show()?;
ui.window().request_redraw();
let mut buf = vec![slint::Rgb8Pixel { r: 0, g: 0, b: 0 }; (w * h) as usize];
let drawn = window.draw_if_needed(|r| {
r.render(buf.as_mut_slice(), w as usize);
});
ui.hide()?;
if !drawn {
anyhow::bail!("software-renderer не отрисовал кадр");
}
write_png(path, w, h, &buf)
}
fn write_png(path: &Path, w: u32, h: u32, buf: &[slint::Rgb8Pixel]) -> anyhow::Result<()> {
let mut enc = png::Encoder::new(std::io::BufWriter::new(std::fs::File::create(path)?), w, h);
enc.set_color(png::ColorType::Rgb);
enc.set_depth(png::BitDepth::Eight);
let mut writer = enc.write_header()?;
let mut data = Vec::with_capacity((w * h * 3) as usize);
for px in buf {
data.extend_from_slice(&[px.r, px.g, px.b]);
}
writer.write_image_data(&data)?;
Ok(())
}
```
- [ ] **Шаг 5 — прогнать тест: PASS.** Run: `cargo test -p shturman-render`. Expected: PASS.
- [ ] **Шаг 6 — рефактор shell на хелпер.** В `crates/apps/shturman-shell/Cargo.toml` добавить `shturman-render = { path = "../shturman-render" }`. В `crates/apps/shturman-shell/src/lib.rs`: удалить локальные `SCREENSHOT_WINDOW`/`ScreenshotPlatform`/`ensure_screenshot_platform`/`write_png`; `render_screenshot` стал:
```rust
pub fn render_screenshot(initial: &Initial, hour: u8, path: &Path) -> anyhow::Result<()> {
let (_, clock) = utc_hh_mm();
shturman_render::render_to_png(|| build_ui(initial, hour, &clock), FRAME_W, FRAME_H, path)?;
tracing::info!(path = %path.display(), "кадр записан (software-render)");
Ok(())
}
```
(Импорты `slint::platform::*`, `std::rc::Rc`, `std::sync::Once` в shell больше не нужны — убрать.)
- [ ] **Шаг 7 — прогнать тесты shell + воркспейс.** Run: `cargo test -p shturman-shell && cargo test --workspace`. Expected: PASS (screenshot-тест shell зелёный на новом хелпере).
- [ ] **Шаг 8 — commit.**
```bash
git add crates/apps/shturman-render crates/apps/shturman-shell Cargo.toml Cargo.lock
git commit -s -m "refactor(v0.2): вынести headless render в shturman-render (shell использует)"
```
---
## P6.2: `shturman-splash` — Stage-0 splash-бинарь
**Files:** Create `crates/apps/shturman-splash/{Cargo.toml,src/lib.rs,src/main.rs,tests/screenshot.rs}`; Modify workspace `Cargo.toml`.
- [ ] **Шаг 1 — падающий тест** `crates/apps/shturman-splash/tests/screenshot.rs`:
```rust
use shturman_splash::render_splash;
#[test]
fn renders_dark_branded_splash() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("splash.png");
render_splash(&path).expect("render_splash");
let dec = png::Decoder::new(std::fs::File::open(&path).unwrap());
let mut r = dec.read_info().unwrap();
assert_eq!((r.info().width, r.info().height), (1024, 600));
let mut buf = vec![0u8; r.output_buffer_size()];
let info = r.next_frame(&mut buf).unwrap();
let px = &buf[..info.buffer_size()];
// фон тёмный (угол) + не одноцветный (wordmark отрисован)
assert!(px[0] < 64 && px[1] < 64 && px[2] < 64, "splash фон не тёмный: {},{},{}", px[0], px[1], px[2]);
assert!(px.iter().any(|&b| b != px[0]), "splash одноцветный — wordmark не отрисован");
}
```
- [ ] **Шаг 2 — `Cargo.toml`** (`crates/apps/shturman-splash/Cargo.toml`) + добавить в workspace `members`:
```toml
[package]
name = "shturman-splash"
version = "0.0.0"
edition.workspace = true
license.workspace = true
[dependencies]
shturman-render = { path = "../shturman-render" }
shturman-common = { path = "../../shturman-common" }
anyhow.workspace = true
tracing.workspace = true
slint.workspace = true
[dev-dependencies]
tempfile.workspace = true
png = "0.17"
```
- [ ] **Шаг 3 — тест падает.** Run: `cargo test -p shturman-splash`. Expected: FAIL (нет `render_splash`).
- [ ] **Шаг 4 — реализация** `crates/apps/shturman-splash/src/lib.rs`:
```rust
//! `shturman-splash` (lib) — Stage-0 splash-кадр (A05). Статичный брендовый кадр (без шины → «мгновенно»).
//! Визуальные токены — каркас (язык design-system — гейт v0.5). Спека v0.2 §6.
use std::path::Path;
slint::slint! {
export component SplashWindow inherits Window {
in property <string> brand: "Штурман";
width: 1024px;
height: 600px;
background: #0e1014;
Text {
text: root.brand;
font-size: 64px;
color: #e8eaed;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
const W: u32 = 1024;
const H: u32 = 600;
/// Headless software-render splash-кадра в PNG (без дисплея/композитора).
pub fn render_splash(path: &Path) -> anyhow::Result<()> {
shturman_render::render_to_png(|| Ok(SplashWindow::new()?), W, H, path)?;
tracing::info!(path = %path.display(), "splash записан (software-render)");
Ok(())
}
```
- [ ] **Шаг 5 — `main.rs`** `crates/apps/shturman-splash/src/main.rs`:
```rust
//! `shturman-splash` (bin) — Stage-0 splash. `--screenshot <path>` → headless PNG (VM/E2E);
//! без аргументов — интерактив (dev/HW; в v0.6 VM используется только screenshot-режим).
use shturman_splash::render_splash;
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
shturman_common::init_tracing("shturman-splash");
match parse_screenshot_arg() {
Some(path) => {
render_splash(&path)?;
println!("{}", path.display());
}
None => {
// интерактив придёт с живым дисплеем (v0.5); в v0 VM splash — только screenshot.
anyhow::bail!("ожидался --screenshot <path> (интерактивный splash — v0.5)");
}
}
Ok(())
}
fn parse_screenshot_arg() -> Option<PathBuf> {
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
if a == "--screenshot" {
return args.next().map(PathBuf::from);
}
if let Some(p) = a.strip_prefix("--screenshot=") {
return Some(PathBuf::from(p));
}
}
None
}
```
- [ ] **Шаг 6 — тест PASS + lint.** Run: `cargo test -p shturman-splash && cargo clippy -p shturman-splash --all-targets -- -D warnings`. Expected: PASS, без warnings.
- [ ] **Шаг 7 — глазами (опц.).** Run: `cargo run -p shturman-splash -- --screenshot target/splash.png` → открыть PNG (тёмный фон + «Штурман»).
- [ ] **Шаг 8 — commit.**
```bash
git add crates/apps/shturman-splash Cargo.toml Cargo.lock
git commit -s -m "feat(v0.2): shturman-splash — Stage-0 splash (software-render → PNG)"
```
---
## P6.3: systemd — фазовые таргеты + splash/warmup + рефактор (зонтик)
**Files:** Create 6 файлов в `systemd/`; Modify `shturman.target` + 5 сервисов.
- [ ] **Шаг 1 — `systemd/tmpfiles-shturman.conf`** (создаёт `/run/shturman` на boot — общий для splash/shell/warmup; tmpfs/volatile, A11):
```
# /run/shturman — volatile-каталог кадров/маркеров (splash.png, frame.png, stage2.ready). A11.
d /run/shturman 0755 root root -
```
- [ ] **Шаг 2 — `systemd/shturman-stage0.target`:**
```ini
[Unit]
Description=Штурман Stage 0 — splash (мгновенно)
Wants=shturman-splash.service
```
- [ ] **Шаг 3 — `systemd/shturman-stage1.target`** (нынешний critical set v0.1):
```ini
[Unit]
Description=Штурман Stage 1 — ядро + первый кадр
Requires=data.mount
After=data.mount
Wants=shturman-firstboot.service shturman-machineid.service shturman-power.service shturman-settings.service shturman-shell.service
```
- [ ] **Шаг 4 — `systemd/shturman-stage2.target`:**
```ini
[Unit]
Description=Штурман Stage 2 — фон (после интерактива)
After=shturman-stage1.target
Wants=shturman-stage2-warmup.service
```
- [ ] **Шаг 5 — `systemd/shturman-splash.service`** (Stage 0; минимум зависимостей; до первого кадра):
```ini
[Unit]
Description=Штурман splash (Stage 0, software-render → PNG)
# «Мгновенно»: без Requires=data.mount/dbus — стартует рано, параллельно critical set.
# Before=shell гарантирует splash.png раньше frame.png. /run/shturman даёт tmpfiles.
After=systemd-tmpfiles-setup.service
Before=shturman-shell.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/shturman-splash --screenshot /run/shturman/splash.png
TimeoutStartSec=15
[Install]
WantedBy=shturman-stage0.target
```
- [ ] **Шаг 6 — `systemd/shturman-stage2-warmup.service`** (плейсхолдер фона; после кадра):
```ini
[Unit]
Description=Штурман Stage 2 warmup (плейсхолдер фона)
After=shturman-shell.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'echo "stage2 warmup" | systemd-cat -t shturman-stage2; : > /run/shturman/stage2.ready'
[Install]
WantedBy=shturman-stage2.target
```
- [ ] **Шаг 7 — рефактор `systemd/shturman.target` → зонтик:**
```ini
[Unit]
Description=Штурман — v0 boot-конвейер (зонтик фаз Stage 0/1/2)
Requires=data.mount
After=data.mount
Wants=shturman-stage0.target shturman-stage1.target shturman-stage2.target
[Install]
WantedBy=multi-user.target
```
- [ ] **Шаг 8 — переключить членство сервисов на Stage 1.** В `systemd/shturman-firstboot.service`, `shturman-machineid.service`, `shturman-power.service`, `shturman-settings.service`, `shturman-shell.service` заменить `WantedBy=shturman.target``WantedBy=shturman-stage1.target`. (Ordering внутри Stage 1 — без изменений: `After=`/`Requires=` в самих юнитах.)
- [ ] **Шаг 9 — у `shturman-shell.service` убрать `RuntimeDirectory=shturman`** (каталог теперь от tmpfiles; иначе рестарт shell снёс бы splash.png). Строку `RuntimeDirectory=shturman` удалить.
- [ ] **Шаг 10 — commit** (конфиги; проверка — в P6.5/Lima).
```bash
git add systemd/
git commit -s -m "feat(v0.2): фазовые systemd-таргеты Stage 0/1/2 + splash/warmup + зонтик"
```
---
## P6.4: justfile + lima provisioning + E2E-блок Stage 0/1/2
**Files:** Modify `justfile`, `lima/shturman.yaml`, `tests/e2e/run.sh`.
- [ ] **Шаг 1 — `justfile`: цель `splash-frame`** (после `shell-frame`):
```just
# инспекция splash-кадра: headless software-render → PNG
splash-frame path="target/splash-frame.png":
cargo run -q -p shturman-splash -- --screenshot {{path}}
@echo "splash записан: {{path}}"
```
- [ ] **Шаг 2 — `lima/shturman.yaml`: разложить новые юниты.** В provision-скрипте, рядом с установкой `systemd/`-юнитов, добавить tmpfiles (новые `.target`/`.service` ставятся тем же `install -m644 /shturman/systemd/shturman-*.service` + явно таргеты):
```bash
install -m644 /shturman/systemd/shturman-stage0.target /etc/systemd/system/
install -m644 /shturman/systemd/shturman-stage1.target /etc/systemd/system/
install -m644 /shturman/systemd/shturman-stage2.target /etc/systemd/system/
install -d /etc/tmpfiles.d
install -m644 /shturman/systemd/tmpfiles-shturman.conf /etc/tmpfiles.d/shturman.conf
systemd-tmpfiles --create /etc/tmpfiles.d/shturman.conf || true
```
(Существующий `install -m644 /shturman/systemd/shturman-*.service` уже подхватит `shturman-splash.service`/`shturman-stage2-warmup.service`.)
- [ ] **Шаг 3 — `tests/e2e/run.sh`: установить splash-бинарь.** В цикле install (`for b in firstboot settings power shell`) добавить `splash`:
```bash
for b in firstboot settings power shell splash; do
sudo install -m755 "$CARGO_TARGET_DIR/release/shturman-$b" /usr/local/bin/ || fail "install shturman-$b"
done
```
- [ ] **Шаг 4 — `tests/e2e/run.sh`: разложить новые юниты + tmpfiles.** ⚠️ `run.sh` ставит сервисы **явным
списком** (НЕ glob — в отличие от lima yaml), поэтому splash/warmup добавляем явно. В блоке раскладки юнитов:
(а) к строке `install … shturman.target … data.mount …` добавить три таргета; (б) к строке install сервисов
добавить splash+warmup; (в) добавить tmpfiles. Итог блока:
```bash
sudo install -m644 systemd/shturman.target systemd/data.mount \
systemd/shturman-stage0.target systemd/shturman-stage1.target systemd/shturman-stage2.target \
/etc/systemd/system/
sudo install -m644 systemd/shturman-firstboot.service systemd/shturman-machineid.service \
systemd/shturman-power.service systemd/shturman-settings.service \
systemd/shturman-shell.service systemd/shturman-splash.service \
systemd/shturman-stage2-warmup.service /etc/systemd/system/
sudo install -d /etc/tmpfiles.d
sudo install -m644 systemd/tmpfiles-shturman.conf /etc/tmpfiles.d/shturman.conf
sudo systemd-tmpfiles --create /etc/tmpfiles.d/shturman.conf || true
```
- [ ] **Шаг 5 — `tests/e2e/run.sh`: блок «Stage 0/1/2»** (вставить после §7 «первый кадр», до §8):
```bash
# ---- Stage 0/1/2 разделены (v0.2) ----
info "Stage 0/1/2: фазы разделены + splash до кадра + warmup после"
for t in shturman-stage0 shturman-stage1 shturman-stage2; do
systemctl is-active --quiet "$t.target" || fail "$t.target не достигнут ($(systemctl is-active "$t.target" 2>&1))"
pass "$t.target reached"
done
sudo test -f /run/shturman/splash.png || fail "нет splash.png (Stage 0)"
sudo head -c8 /run/shturman/splash.png | od -An -tx1 | tr -d ' \n' | grep -qi 89504e47 || fail "splash.png не PNG"
sp=$(sudo stat -c %Y /run/shturman/splash.png); fr=$(sudo stat -c %Y /run/shturman/frame.png)
[ "$sp" -le "$fr" ] || fail "splash.png позже frame.png ($sp > $fr)"
sudo test -f /run/shturman/stage2.ready || fail "нет stage2.ready (warmup не отработал)"
w2=$(sudo stat -c %Y /run/shturman/stage2.ready)
[ "$w2" -ge "$fr" ] || fail "stage2.ready ($w2) раньше кадра ($fr)"
pass "порядок фаз: splash($sp) ≤ frame($fr) ≤ stage2($w2)"
# boot-тайминг (функц., НЕ гейт; вердикт — RK3588)
echo " $(systemd-analyze time 2>/dev/null | head -1 || echo 'systemd-analyze н/д')"
```
- [ ] **Шаг 6 — commit.**
```bash
git add justfile lima/shturman.yaml tests/e2e/run.sh
git commit -s -m "feat(v0.2): splash-frame + lima/E2E раскладка Stage 0/1/2"
```
---
## P6.5: verify в Lima + acceptance + синхронизация доков
- [ ] **Шаг 1 — host-гейт.** Run: `just ci`. Expected: exit 0 (все unit-тесты, включая `shturman-render`/`shturman-splash`; clippy; deny).
- [ ] **Шаг 2 — чистый E2E с нуля.** Run: `just vm-reset && just e2e`. Expected: exit 0; в выводе — `shturman-stage0/1/2.target reached`, `splash($sp) ≤ frame($fr) ≤ stage2($w2)`, **вся приёмка v0.1/v0.6 зелёная** (нет регресса), `E2E OK ✅`.
- [ ] **Шаг 3 — если падёт — итерировать** по реальным ошибкам (ordering таргетов, glob юнитов, tmpfiles, splash before shell) и повторить P6.5 шаг 2. (Систематически: один симптом → одна правка.)
- [ ] **Шаг 4 — синхронизация доков (швы спеки §10):** в `docs/domains/a-base-system.md` §4 / `docs/architecture.md` §6 — пометка «dev-VM Stage-0-splash = systemd software-render PNG; U-Boot framebuffer — HW»; `docs/roadmap.md` §v0.2 → ✅; `docs/specs/v0.1-v0.6-foundation.md` §13 — шов «shturman.target → зонтик, critical set → stage1.target»; `CLAUDE.md` — статус v0.2 готово → следующее v0.3/v0.5.
- [ ] **Шаг 5 — commit доков.**
```bash
git add docs/ CLAUDE.md
git commit -s -m "docs(v0.2): синхронизация швов boot-конвейера + статус"
```
- [ ] **Шаг 6 — finishing-a-development-branch** (merge/PR — спросить пользователя; в `main` без явного «ок» не мержить).
---
## Acceptance (спека v0.2 §9.3)
- [ ] `shturman.target` = зонтик; `shturman-stage0/1/2.target` достижимы и разделены (per-target active).
- [ ] Splash-кадр (`splash.png`) непустой и рендерится **раньше** первого кадра Shell (`frame.png`).
- [ ] Stage 2 (warmup) стартует **после** первого кадра (`stage2.ready` mtime ≥ frame).
- [ ] Boot-тайминг логируется (`systemd-analyze`); <10 c — пометка «вердикт на RK3588».
- [ ] Вся приёмка v0.1/v0.6 (foundation §9.4) — зелёная на фазовой раскладке (нет регресса).
- [ ] `just ci` зелёный; красные линии целы (нет CAN/actuator).