← All Pull Requests

Review components early with optional progress widget and bounded library tours #6

Merged opened by John Lauer 2026-09-16

Adds an early component review and an optional Hydrogen progress widget for long board runs. The widget follows the current step and saved image artifacts, caches up to three recent thumbnails, and supports explicit component/milestone images. Default is off; disable prevents ledger scans, thumbnail work and event writes. It makes no AI/provider calls. The percentage counts completed planned steps or an explicitly scoped milestone, never guessed time remaining. Returns and timestamped older thumbnails remain visible.

The component workflow inventories all references before placement, searches the wiki before external sources, keeps global reusable parts separate from board-only definitions, and requires per-file provenance with honest unknowns. Optional marked variants retain plain assets and lineage; MPN text follows the longest usable top-face direction with pin-1 clearance. The additional tour helper selects explicit STEP/GLB hashes and variants, rejects malformed GLBs and missing marked variants, and records a separate detailed tour plus a five-second overview.

Validation: release binary rebuilt; real ESC integration passed for 149 references, marking choice persistence, preserved review notes, stale-source invalidation and malformed-register preservation. Widget tests passed for opt-out/no writes, selected-run state, planned-step progress, milestone overrides and untouched run.json/run.jsonl. The rebuilt CLI's widget enable/event/status/disable sequence passed on a disposable run. Live Hydrogen title-bar rendering was inspected: 38px slot, three loaded thumbnails, current activity and progress basis. Screenshot included. The helper was left enabled for John's ESC run, as requested.

The published source snapshot still lacks release compose/tour commands (issue #16). This PR must be integrated into the maintainer's complete release tree; do not overwrite the installed production binary with this snapshot. The standalone tour helper is additive; automatic final-compose selection is not claimed implemented or released. Global widget discovery additionally requires the curated adom/hydrogen-bootstrap entry after publishing the helper in the package.

Supersedes PR #5 (and earlier proposals) with the widget, longest-axis marking guidance and separate tour helper. Component audit: https://wiki.adom.inc/adom/esc-g431/files/docs/astra/component-quality/README.md . The source mismatch is https://wiki.adom.inc/adom/adom-aiflow/issues/16 . The binary-download defect discovered during live testing is https://wiki.adom.inc/adom/adom-wiki-cli/issues/56 ; all 40 audited pages now have corrected binary hashes.

Live tour validation completed: all 40 models loaded (2801 meshes, 57507 vertices), detailed and overview recordings were made and contact sheets inspected. The encoder now explicitly caps output at 24 fps and 1280x720, refuses files above 10 MB and checks for duplicate-frame inflation. This fixes the browser WebM time base being interpreted as 1000 fps by default ffmpeg conversion. Published clips are about 1.02 MB and 0.10 MB. The short cut is approximately 4.33 seconds; detail approximately 80.42 seconds. Source STEP and GLB hashes are validated; model qualification and native-library binding are still separate review obligations.

Diff Skip to comments

--- a/crates/adom-aiflow/src/main.rs+++ b/crates/adom-aiflow/src/main.rs@@ -1,1094 +1,1153 @@⋯ 27 unchanged lines ⋯ enum Cmd {     /// Start a run: copies the board, stamps the prompt time     Start { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long)] engine: String, #[arg(long)] prompt_time: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] remote_board: Option<String>, #[arg(long)] force: bool },+    /// Inventory every board reference for the early component quality review; optional MPN marking is an explicit preference.+    Components { #[arg(long, value_parser = ["ask", "off", "on"])] etch: Option<String> },+    /// Optional Hydrogen progress widget: enable, disable, status, or publish an existing thumbnail.+    Widget { #[arg(value_parser = ["enable", "disable", "status", "event"])] action: String, #[arg(long)] file: Option<PathBuf>, #[arg(long)] label: Option<String>, #[arg(long)] step: Option<String>, #[arg(long)] progress: Option<f64> },     /// The stages this board needs and who can take each     Plan,     /// Record who takes a stage: route=ai pour=binary⋯ 459 unchanged lines ⋯             let _ = TURN.set((dir.clone(), idx));             ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &[format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);         }+        Cmd::Widget { action, file, label, step, progress } => {+            let t = thread(&cli);+            let _r = load_run(&cli);+            let mut cmd = std::process::Command::new("adom-aiflow-widget");+            cmd.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t);+            if let Some(v) = file { cmd.arg("--file").arg(v); }+            if let Some(v) = label { cmd.arg("--label").arg(v); }+            if let Some(v) = step { cmd.arg("--step").arg(v); }+            if let Some(v) = progress { cmd.arg("--progress").arg(v.to_string()); }+            let status = cmd.status().unwrap_or_else(|e| err(&format!("widget helper unavailable: {e}"), &["Install the package's adom-aiflow-widget helper; the board run is unchanged.".into()]));+            if !status.success() { err("widget helper failed", &[]); }+            ok("widget preference or artifact updated", &["Optional: open AI Flow progress from Hydrogen Widgets. Reads the run and existing artifacts without AI calls. Disable stops feed updates and new thumbnails.".into()]);+        }+        Cmd::Components { etch } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let b = board_of(&r);+            let path = dir.join("components.json");+            let old: Value = if path.exists() {+                let text = std::fs::read_to_string(&path).unwrap_or_else(|e| err(&format!("read components register: {e}"), &[]));+                serde_json::from_str(&text).unwrap_or_else(|e| err(&format!("invalid components register (left untouched): {e}"), &[]))+            } else { json!({}) };+            let preference = etch.as_deref().or_else(|| old["mpnMarking"].as_str()).unwrap_or("ask");+            let mut entries = Vec::new();+            for fp in b.root.find_all("footprint") {+                let props: BTreeMap<String, String> = fp.find_all("property").filter_map(|p| Some((p.atom(1)?.into(), p.atom(2)?.into()))).collect();+                let Some(reference) = props.get("Reference") else { continue; };+                let mut entry = old["components"].as_array().and_then(|a| a.iter().find(|e| e["reference"].as_str() == Some(reference.as_str()))).cloned().unwrap_or_else(|| json!({"reference": reference, "review": {"status": "pending", "classification": "unreviewed", "wikiPage": null, "geometryEvidence": [], "visualEvidence": [], "redistributionEvidence": [], "assetProvenance": [], "variants": []}}));+                let models = json!(fp.find_all("model").filter_map(|n| n.value()).collect::<Vec<_>>());+                if entry.get("properties").is_some() && (entry["properties"] != json!(props) || entry["footprint"] != json!(fp.value()) || entry["modelReferences"] != models) {+                    entry["review"]["status"] = json!("stale");+                    entry["review"]["staleReason"] = json!("Board properties, footprint or model references changed; re-review retained evidence.");+                }+                entry["properties"] = json!(props);+                entry["footprint"] = json!(fp.value());+                entry["modelReferences"] = models;+                entries.push(entry);+            }+            let report = json!({"schemaVersion": 1, "board": b.path, "mpnMarking": preference, "components": entries,+                "qualityGate": "AI review required; this inventory does not certify identities, geometry, redistribution rights or native rendering"});+            std::fs::write(&path, serde_json::to_string_pretty(&report).unwrap()).unwrap_or_else(|e| err(&format!("write component register: {e}"), &[]));+            r.data["components"] = json!({"register": path, "mpnMarking": preference, "reviewStatus": "pending"});+            r.save().unwrap_or_else(|e| err(&e, &[]));+            let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+            let step = flow["steps"].as_array().unwrap().iter().find(|s| s["name"] == "components").unwrap();+            let mut hints: Vec<String> = step["workflow"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_owned).collect();+            hints.push(format!("MPN marking preference: {preference}. 'ask' means offer the option, not consent; 'off' preserves plain models; 'on' requests additional reviewed marked variants. Existing review notes are preserved on rerun; re-review when source properties or geometry changes."));+            ok(&format!("component register: {} ({} references); AI review is pending", path.display(), report["components"].as_array().unwrap().len()), &hints);+        }         Cmd::Plan => {             let mut r = load_run(&cli);             // the flow is a file: the steps in order, who does each, what the binary offers, what comes later⋯ 20 unchanged lines ⋯             lines.push(format!("flow \"{}\": {}", flow["name"].as_str().unwrap_or(""), flow["scope"].as_str().unwrap_or("")));             for st in flow["steps"].as_array().cloned().unwrap_or_default() {                 lines.push(format!("  step {:<10} {:<7} {}", st["name"].as_str().unwrap_or(""), st["who"].as_str().unwrap_or(""), st["does"].as_str().unwrap_or("")));+                if let Some(workflow) = st["workflow"].as_array() {+                    for (i, action) in workflow.iter().filter_map(Value::as_str).enumerate() {+                        lines.push(format!("    {}. {}", i + 1, action));+                    }+                }             }             lines.push(format!("  later: {}", flow["later"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()));             lines.push("stages the binary offers:".into());⋯ 567 unchanged lines ⋯         }     } }+--- a/flows/board.json+++ b/flows/board.json@@ -1,162 +1,189 @@⋯ 5 unchanged lines ⋯     {       "name": "intake",       "who": "ai",-      "does": "read the board and the spec, write the spec from the schematic if it is missing, plan",+      "does": "read the board and the spec, write the spec from the schematic if it is missing, plan; offer the optional Hydrogen progress widget (default off; enable when requested), reusing saved milestone images without extra AI calls",       "record": "nothing on screen yet: the clip is the board opening on the test box (capture open) and the spec being read"     },     {+      "name": "components",+      "who": "ai",+      "does": "Audit wiki component identity, reusable CAD quality and redistribution evidence before placement; offer cached optional MPN-marked variants.",+      "binary": [+        "components"+      ],+      "workflow": [+        "Inventory every reference from the actual board and schematic: manufacturer, MPN, supplier code, package, value and ratings; group repeated exact parts and record their references. Do not invent an MPN or silently substitute a similar value/package. Distinguish populated parts, DNP parts and bare copper/mechanical features; a bare test pad needs no purchased component or fictitious 3D body.",+        "Search the wiki FIRST for each exact manufacturer/MPN and supplier code; reuse the matching component page and inspect its symbol, footprint and STEP/WRL assets. A page existing is not proof its CAD bundle is complete. Record the page URL and missing assets per reference.",+        "For missing identities or assets, use adom-parts-search next, then original manufacturer and distributor websites. Use Pup to navigate and download through the automated browser when curl/fetch is blocked or the site requires JavaScript; do not treat a blocked fetch as proof the part is unavailable. Read the relevant parts-search and Pup skills for current commands.",+        "Verify manufacturer, exact ordering code, package dimensions, pin numbering and required electrical ratings against source evidence before accepting CAD or a candidate part. Preserve source URLs and provenance. Label generated or approximate models as such; never present an approximate body as a verified vendor model.",+        "Global component pages are shared resources for ALL ADOM USERS, never a board-specific BOM dump. Create one only for a distinct reusable manufacturer part or independently specified reusable custom component, after searching for duplicates. Write a part-focused page with portable verified assets and provenance, and improve existing pages. Keep board references, unresolved identities, one-off land patterns, bare copper test pads and project-only assemblies inside the board project wiki page; do not create catalog placeholders or rename a one-off feature to make it appear global.",+        "Use portable project/library model paths, preserve placement and routing, rerun kicad_model_check and inspect the saved board in native KiCad 3D. Report reference coverage, reused/created page URLs, unresolved identities and missing CAD separately. A successful download or wiki publication alone does not close a missing-model finding.",+        "Judge quality per component, not per board screenshot: publish a linked visual register with top, bottom and oblique views. Check dimensions, units, terminal count and pitch, pin-1/polarity, body/pad alignment, standoff, materials and visible details. File resolution alone is not quality. Record pass, needs-work, reference-only or unknown with evidence and limits.",+        "Prefer manufacturer CAD when the source permits the intended redistribution. Keep third-party/Ultra Librarian downloads as private reference-only inputs unless redistribution is explicitly permitted. Compare independently generated models against manufacturer drawings and permitted reference views; retain dimensional deviations and source hashes. Converting, extracting, recoloring, or etching a restricted model does not make it independently authored or license-cleared.",+        "Offer optional MPN marking using adom/adom-chip-laser or the current adom-step2glb laser-etch service. Ask whether to enable it; default to unmarked models until the user chooses. Check the shared page cache first. Preserve the plain STEP and publish an additional marked STEP, derived GLB and reviewed thumbnails only for assets eligible for redistribution. Keep the mark clear of pin-1, polarity, terminals and optical/mechanical features. Marking is an identification aid, not evidence of actual factory top-marking, especially on tiny passives.",+        "Cache reviewed artifacts on the existing global component page: source/plain STEP, optional MPN STEP, GLB, thumbnails and machine-readable provenance. Record input hash, generator/tool version, parameters, units, transforms, marking text/mode, reference evidence and review results. Cache keys must change when geometry, text or generator parameters change. Keep board-only transforms and mappings in the board project. Never claim cross-EDA parity without rendering the variant in the named native viewers.",+        "When publishing or improving a component page, make per-file provenance mandatory even though creating a new page is optional. Record original source URL/file and revision, retrieval date, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator/version and parameters, units/transforms, redistribution evidence and limitations. For AI-created geometry cite the actual datasheet page/figure/table and dimensions used, list simplifications and reference comparisons, and never present copied/extracted CAD as independent work. State which checks ran and which remain unverified; retain plain and marked variant lineage. Put a readable provenance section on the page plus a machine-readable asset record and a link to that component's issue tracker. Unknown provenance stays unknown, not a fabricated source. Reuse/improve existing pages first; offer new global-page publication only for reusable components, keeping board-specific records in the project.",+        "For optional MPN marking, fit the text along the longest usable top-face direction with the largest legible size, respecting pin-1 and mechanical features. Compare both orientations and retain the package coordinate frame. Cache and identify the marked variant explicitly.",+        "Offer the optional Hydrogen progress widget. If enabled, reuse saved component, marked-model, symbol and later board/analysis thumbnails via widget event; do not generate extra screenshots or call a model solely for the widget. Respect widget disable immediately."+      ]+    },+    {       "name": "models",       "who": "ai",-      "does": "every footprint on the board has its 3D model resolved (kicad_model_check): fetch the vendor STEP, build one, or fix the path, so the board renders as it will be built; a bare footprint in the 3D view is a missing model",+      "does": "audit every board component and missing symbol, footprint or 3D asset; resolve exact identities on the wiki first, then adom-parts-search and manufacturer/supplier sites (Pup when browser access is needed); publish missing component pages, repair portable model paths, and verify the native 3D board with kicad_model_check",       "binary": [         "models"       ],-      "record": "nothing to film: the model check's list and the fixes; the 3D walkthrough later is the proof"+      "record": "nothing to film: the model check's list and the fixes; the 3D walkthrough later is the proof",+      "workflow": [+        "Use the components register and its reviewed plain or explicitly selected marked variants; fix portable model paths, run kicad_model_check, then inspect the native board render. Do not substitute a model merely to make the missing-file gate pass."+      ]     },     {       "name": "placement",⋯ 137 unchanged lines ⋯   "clips": "every `step <name>` stops the previous step's clip and starts a new window recording tagged with the step, when the board is open on a test box; run.json captures[] carries one entry per clip with its step, start, stop and file, and deliver lists them; the final video is cut from these clips, one segment per step, so two engines' videos line up step for step; a return (step <name> --back --why) is a new visit and gets its own clip, tagged <step>-<visit> with the reason, so the rework is on camera and the final cut can show the loop",   "screenshots": "every step visit gets two background screenshots of the editor window, at its start and at its end (shot-<step>-<visit>-start.png, shot-<step>-<visit>-end.png), logged as artifacts, so a run's own README has a picture for every step without anyone taking one" }+--- a/SKILL.md+++ b/SKILL.md@@ -1,83 +1,114 @@⋯ 80 unchanged lines ⋯ - `finish` is the only thing that ends a run. A run without `finish` is not a result, and its minutes are still counting. - KiCad drops pour islands that touch nothing: a heat spreader drawn across dense routing on the other layer fills as fragments and reads small in `measure`. Put the copper where the layer is actually free (the analysis numbers say when it is not). - 0.1's analyses are conservative heuristics (IPC-2221 for tracks, presence, connection style and vias for pours and tabs); they say so in their output. 0.2 computes cross-sections through the filled copper.++## Component coverage before placement++The early `components` step audits component identity, library coverage and model quality before `models` and placement. Run `plan` to read its ordered sourcing workflow. Inventory every reference on the actual board and schematic, and track exact manufacturer/MPN, supplier code, package and required ratings. Reuse exact wiki component pages first; check their assets rather than assuming a page contains a complete CAD bundle. Use `adom-parts-search` next and manufacturer/distributor sites for gaps. Pup is the browser fallback for JavaScript or blocked curl/fetch downloads; read its skill for current commands.++Publish missing identified component pages with verified symbol/footprint/model assets and provenance; improve existing pages instead of duplicating them. Do not invent identities for generic land patterns, DNP parts or bare copper test pads, or silently substitute a similar part. Record unresolved identities and missing assets separately. Generated/approximate bodies must be labelled. Keep model paths portable, preserve layout, rerun `kicad_model_check`, and visibly inspect native KiCad 3D before calling model coverage complete. The AI performs this sourcing audit; these instructions do not add an automatic sourcing or component-identity gate.++Global component pages serve ALL ADOM USERS. Publish only distinct reusable manufacturer parts or independently specified reusable custom components, after duplicate checking. Keep page titles and assets part-focused and portable. Board reference mappings, unresolved identities, one-off footprints, bare copper features and project-only assemblies belong in the board project wiki page, not new component catalog entries. Do not turn project-specific placeholders into generic-looking pages merely by renaming them.++## Early component quality and optional MPN marking++Before placement, declare `step components` and run `components` with your `--run` and `--ai-thread`. It writes `components.json`, inventories every board reference and preserves review notes on rerun. This is an AI review register, not an automated geometry or licensing certification. Record wiki links, classification, dimensions, terminals, pin-1/polarity, plain/marked variants, top/bottom/oblique visual evidence and redistribution evidence. Unknowns remain unknown.++Use manufacturer drawings and permitted CAD as reference checks. Third-party downloads may be private reference-only; never redistribute them without permission, or call extracted/recolored/etched geometry independently authored. Compare independently generated geometry with dimensional and visual evidence. Publish only reusable assets on global pages; board-only definitions stay in the project.++Offer `components --etch on` or `--etch off` (initial default `ask` is not consent). Discover adom/adom-chip-laser and the current STEP engine. Keep the plain model; cache a separate MPN-marked STEP, GLB and reviewed thumbnails when enabled, with input hash, generator version/parameters, text/mode and native-render evidence. Protect pin-1, polarity and functional features. Marking is an identification aid, not a claim of real factory markings. Do not regenerate a valid shared cache, and do not claim KiCad/Fusion/Altium parity until each named viewer was checked.++## Provenance is required for shared component assets++Creating a new global component page is optional; truthful per-file provenance is required when publishing or improving one. Reuse and improve existing pages first. Keep board-only definitions in the board project. Add a readable provenance section and machine-readable asset record, linked prominently from the component README (including HTML READMEs without removing their existing content).++For every symbol, footprint, STEP, GLB and optional marked variant, record the source URL/file/revision and retrieval time, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator and version, parameters, units, transforms, modifications, redistribution evidence, checks performed and unresolved limits. A hash proves file identity, not quality or permission. Existing wiki availability alone does not establish upstream authorship or rights.++For AI-created geometry, explicitly say it is AI-created and identify the datasheet page/figure/table, dimensional numbers and assumptions used by the generator. Publish the generator or reproducible parameters, disclose simplifications, and distinguish independent geometry from a converted/extracted reference. Keep restricted reference CAD private; document what the comparison tested and did not test. Preserve the lineage from plain input to marked STEP to GLB, including full marking text/mode.++Link to the component page's issue tracker and ask reviewers to include the filename/hash, disputed dimension or pin, source evidence, EDA/version and screenshot. Never fill provenance gaps with plausible guesses. Unknown origins and failed or missing validation remain visible until supported by evidence.++## Optional live progress++Offer the Hydrogen progress widget at intake; default off unless requested. Use `widget enable`, then open AI Flow progress in Hydrogen Widgets. Reuse saved milestone PNGs through `widget event --file <inside-run image> --label <milestone>`; the service also follows saved ledger artifacts and step changes. Use it for component models, longest-axis MPN variants, symbols, placement, pours and Fields. `widget disable` stops updates and thumbnail work. No AI calls or extra captures are required; the progress bar counts planned steps, not remaining time. See widgets/README.md.+--- a/skills/adom-aiflow/SKILL.md+++ b/skills/adom-aiflow/SKILL.md@@ -1,83 +1,114 @@⋯ 80 unchanged lines ⋯ - `finish` is the only thing that ends a run. A run without `finish` is not a result, and its minutes are still counting. - KiCad drops pour islands that touch nothing: a heat spreader drawn across dense routing on the other layer fills as fragments and reads small in `measure`. Put the copper where the layer is actually free (the analysis numbers say when it is not). - 0.1's analyses are conservative heuristics (IPC-2221 for tracks, presence, connection style and vias for pours and tabs); they say so in their output. 0.2 computes cross-sections through the filled copper.++## Component coverage before placement++The early `components` step audits component identity, library coverage and model quality before `models` and placement. Run `plan` to read its ordered sourcing workflow. Inventory every reference on the actual board and schematic, and track exact manufacturer/MPN, supplier code, package and required ratings. Reuse exact wiki component pages first; check their assets rather than assuming a page contains a complete CAD bundle. Use `adom-parts-search` next and manufacturer/distributor sites for gaps. Pup is the browser fallback for JavaScript or blocked curl/fetch downloads; read its skill for current commands.++Publish missing identified component pages with verified symbol/footprint/model assets and provenance; improve existing pages instead of duplicating them. Do not invent identities for generic land patterns, DNP parts or bare copper test pads, or silently substitute a similar part. Record unresolved identities and missing assets separately. Generated/approximate bodies must be labelled. Keep model paths portable, preserve layout, rerun `kicad_model_check`, and visibly inspect native KiCad 3D before calling model coverage complete. The AI performs this sourcing audit; these instructions do not add an automatic sourcing or component-identity gate.++Global component pages serve ALL ADOM USERS. Publish only distinct reusable manufacturer parts or independently specified reusable custom components, after duplicate checking. Keep page titles and assets part-focused and portable. Board reference mappings, unresolved identities, one-off footprints, bare copper features and project-only assemblies belong in the board project wiki page, not new component catalog entries. Do not turn project-specific placeholders into generic-looking pages merely by renaming them.++## Early component quality and optional MPN marking++Before placement, declare `step components` and run `components` with your `--run` and `--ai-thread`. It writes `components.json`, inventories every board reference and preserves review notes on rerun. This is an AI review register, not an automated geometry or licensing certification. Record wiki links, classification, dimensions, terminals, pin-1/polarity, plain/marked variants, top/bottom/oblique visual evidence and redistribution evidence. Unknowns remain unknown.++Use manufacturer drawings and permitted CAD as reference checks. Third-party downloads may be private reference-only; never redistribute them without permission, or call extracted/recolored/etched geometry independently authored. Compare independently generated geometry with dimensional and visual evidence. Publish only reusable assets on global pages; board-only definitions stay in the project.++Offer `components --etch on` or `--etch off` (initial default `ask` is not consent). Discover adom/adom-chip-laser and the current STEP engine. Keep the plain model; cache a separate MPN-marked STEP, GLB and reviewed thumbnails when enabled, with input hash, generator version/parameters, text/mode and native-render evidence. Protect pin-1, polarity and functional features. Marking is an identification aid, not a claim of real factory markings. Do not regenerate a valid shared cache, and do not claim KiCad/Fusion/Altium parity until each named viewer was checked.++## Provenance is required for shared component assets++Creating a new global component page is optional; truthful per-file provenance is required when publishing or improving one. Reuse and improve existing pages first. Keep board-only definitions in the board project. Add a readable provenance section and machine-readable asset record, linked prominently from the component README (including HTML READMEs without removing their existing content).++For every symbol, footprint, STEP, GLB and optional marked variant, record the source URL/file/revision and retrieval time, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator and version, parameters, units, transforms, modifications, redistribution evidence, checks performed and unresolved limits. A hash proves file identity, not quality or permission. Existing wiki availability alone does not establish upstream authorship or rights.++For AI-created geometry, explicitly say it is AI-created and identify the datasheet page/figure/table, dimensional numbers and assumptions used by the generator. Publish the generator or reproducible parameters, disclose simplifications, and distinguish independent geometry from a converted/extracted reference. Keep restricted reference CAD private; document what the comparison tested and did not test. Preserve the lineage from plain input to marked STEP to GLB, including full marking text/mode.++Link to the component page's issue tracker and ask reviewers to include the filename/hash, disputed dimension or pin, source evidence, EDA/version and screenshot. Never fill provenance gaps with plausible guesses. Unknown origins and failed or missing validation remain visible until supported by evidence.++## Optional live progress++Offer the Hydrogen progress widget at intake; default off unless requested. Use `widget enable`, then open AI Flow progress in Hydrogen Widgets. Reuse saved milestone PNGs through `widget event --file <inside-run image> --label <milestone>`; the service also follows saved ledger artifacts and step changes. Use it for component models, longest-axis MPN variants, symbols, placement, pours and Fields. `widget disable` stops updates and thumbnail work. No AI calls or extra captures are required; the progress bar counts planned steps, not remaining time. See widgets/README.md.+--- a/docs/component-sourcing.md+++ b/docs/component-sourcing.md@@ -0,0 +1,28 @@+# Wiki-first component and CAD coverage++## Component coverage before placement++The early `components` step audits component identity, library coverage and model quality before `models` and placement. Run `plan` to read its ordered sourcing workflow. Inventory every reference on the actual board and schematic, and track exact manufacturer/MPN, supplier code, package and required ratings. Reuse exact wiki component pages first; check their assets rather than assuming a page contains a complete CAD bundle. Use `adom-parts-search` next and manufacturer/distributor sites for gaps. Pup is the browser fallback for JavaScript or blocked curl/fetch downloads; read its skill for current commands.++Publish missing identified component pages with verified symbol/footprint/model assets and provenance; improve existing pages instead of duplicating them. Do not invent identities for generic land patterns, DNP parts or bare copper test pads, or silently substitute a similar part. Record unresolved identities and missing assets separately. Generated/approximate bodies must be labelled. Keep model paths portable, preserve layout, rerun `kicad_model_check`, and visibly inspect native KiCad 3D before calling model coverage complete. The AI performs this sourcing audit; these instructions do not add an automatic sourcing or component-identity gate.++Global component pages serve ALL ADOM USERS. Publish only distinct reusable manufacturer parts or independently specified reusable custom components, after duplicate checking. Keep page titles and assets part-focused and portable. Board reference mappings, unresolved identities, one-off footprints, bare copper features and project-only assemblies belong in the board project wiki page, not new component catalog entries. Do not turn project-specific placeholders into generic-looking pages merely by renaming them.++## Early component quality and optional MPN marking++Before placement, declare `step components` and run `components` with your `--run` and `--ai-thread`. It writes `components.json`, inventories every board reference and preserves review notes on rerun. This is an AI review register, not an automated geometry or licensing certification. Record wiki links, classification, dimensions, terminals, pin-1/polarity, plain/marked variants, top/bottom/oblique visual evidence and redistribution evidence. Unknowns remain unknown.++Use manufacturer drawings and permitted CAD as reference checks. Third-party downloads may be private reference-only; never redistribute them without permission, or call extracted/recolored/etched geometry independently authored. Compare independently generated geometry with dimensional and visual evidence. Publish only reusable assets on global pages; board-only definitions stay in the project.++Offer `components --etch on` or `--etch off` (initial default `ask` is not consent). Discover adom/adom-chip-laser and the current STEP engine. Keep the plain model; cache a separate MPN-marked STEP, GLB and reviewed thumbnails when enabled, with input hash, generator version/parameters, text/mode and native-render evidence. Protect pin-1, polarity and functional features. Marking is an identification aid, not a claim of real factory markings. Do not regenerate a valid shared cache, and do not claim KiCad/Fusion/Altium parity until each named viewer was checked.++## Provenance is required for shared component assets++Creating a new global component page is optional; truthful per-file provenance is required when publishing or improving one. Reuse and improve existing pages first. Keep board-only definitions in the board project. Add a readable provenance section and machine-readable asset record, linked prominently from the component README (including HTML READMEs without removing their existing content).++For every symbol, footprint, STEP, GLB and optional marked variant, record the source URL/file/revision and retrieval time, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator and version, parameters, units, transforms, modifications, redistribution evidence, checks performed and unresolved limits. A hash proves file identity, not quality or permission. Existing wiki availability alone does not establish upstream authorship or rights.++For AI-created geometry, explicitly say it is AI-created and identify the datasheet page/figure/table, dimensional numbers and assumptions used by the generator. Publish the generator or reproducible parameters, disclose simplifications, and distinguish independent geometry from a converted/extracted reference. Keep restricted reference CAD private; document what the comparison tested and did not test. Preserve the lineage from plain input to marked STEP to GLB, including full marking text/mode.++Link to the component page's issue tracker and ask reviewers to include the filename/hash, disputed dimension or pin, source evidence, EDA/version and screenshot. Never fill provenance gaps with plausible guesses. Unknown origins and failed or missing validation remain visible until supported by evidence.+--- a/tests/component-workflow.py+++ b/tests/component-workflow.py@@ -0,0 +1,27 @@+"""Exercise early component inventory, optional marking and review-note preservation."""+import pathlib,subprocess,tempfile,sys,json+binary,board,spec=map(lambda p:str(pathlib.Path(p).resolve()),sys.argv[1:])+with tempfile.TemporaryDirectory(prefix='aiflow-components-') as d:+ base=[binary,'--ai-thread','ESC AI Flow Astra','--run',d]+ subprocess.run(base+['start','--board',board,'--spec',spec,'--engine','component-workflow-test'],check=True,capture_output=True,text=True)+ text=subprocess.run(base+['plan'],check=True,capture_output=True,text=True).stdout+ assert text.index('step components')<text.index('step placement')+ assert text.index('Search the wiki FIRST')<text.index('use adom-parts-search next')+ for phrase in ['ALL ADOM USERS','private reference-only','optional MPN marking','Cache keys','per-file provenance mandatory','datasheet page/figure/table','Unknown provenance stays unknown']:+  assert phrase in text,phrase+ result=subprocess.run(base+['components'],check=True,capture_output=True,text=True)+ p=pathlib.Path(d)/'components.json';v=json.load(open(p));assert len(v['components'])==149;assert v['mpnMarking']=='ask'+ assert all('assetProvenance' in r['review'] for r in v['components'])+ refs=[r['reference'] for r in v['components']];assert len(set(refs))==149+ v['components'][0]['review']['notes']='Preserve this independent review';p.write_text(json.dumps(v))+ subprocess.run(base+['components','--etch','on'],check=True,capture_output=True,text=True)+ v=json.load(open(p));assert v['mpnMarking']=='on';assert v['components'][0]['review']['notes']=='Preserve this independent review'+ subprocess.run(base+['components'],check=True,capture_output=True,text=True);assert json.load(open(p))['mpnMarking']=='on'+ subprocess.run(base+['components','--etch','off'],check=True,capture_output=True,text=True);assert json.load(open(p))['mpnMarking']=='off'+ v=json.load(open(p));v['components'][0]['properties']['Value']='deliberately stale source value';v['components'][0]['review']['status']='pass';p.write_text(json.dumps(v))+ subprocess.run(base+['components'],check=True,capture_output=True,text=True)+ v=json.load(open(p));assert v['components'][0]['review']['status']=='stale';assert v['components'][0]['review']['notes']=='Preserve this independent review'+ p.write_text('{invalid');r=subprocess.run(base+['components'],capture_output=True,text=True);assert r.returncode!=0;assert p.read_text()=='{invalid'+ print(result.stdout)+ print('PASS: early plan order, 149 references, explicit marking preference, preserved review notes, stale source invalidation, malformed register untouched.')+--- a/tools/aiflow-widget.py+++ b/tools/aiflow-widget.py@@ -0,0 +1,104 @@+#!/usr/bin/env python3+"""Opt-in Hydrogen progress widget. Reuses artifacts; never calls an AI/provider."""+import argparse,pathlib,json,os,time,datetime,hashlib,subprocess,http.server,urllib.request,signal,re+CONFIG=pathlib.Path.home()/'.adom/aiflow-widget.json'+p=argparse.ArgumentParser();p.add_argument('action',choices=['enable','disable','serve','stop','event','status']);p.add_argument('--ai-thread');p.add_argument('--run',type=pathlib.Path);p.add_argument('--port',type=int,default=int(os.environ.get('HYDROGEN_WIDGET_PORT','27020')));p.add_argument('--file',type=pathlib.Path);p.add_argument('--label');p.add_argument('--step');p.add_argument('--progress',type=float);a=p.parse_args()+def read(p,default):+ try:return json.loads(p.read_text())+ except (OSError,ValueError):return default++def write(p,data):p.parent.mkdir(parents=True,exist_ok=True);q=p.with_suffix('.tmp');q.write_text(json.dumps(data,indent=2));q.replace(p)+def cfg():return read(CONFIG,{'enabled':False})+_ledger_cache={}+def thumbnail(run,src):+ src=src.resolve()+ if not src.is_relative_to(run.resolve()) or not src.is_file():return None+ name=hashlib.sha256(src.read_bytes()).hexdigest()+'.png';dest=run/'widget-thumbs'/name;dest.parent.mkdir(exist_ok=True)+ if not dest.exists():subprocess.run(['ffmpeg','-y','-v','error','-i',str(src),'-vf','scale=256:192:force_original_aspect_ratio=decrease','-frames:v','1',str(dest)],check=True,timeout=15)+ return name++def ledger_events(run):+ path=run/'run.jsonl'+ if not path.exists():return []+ stat=path.stat();key=(str(path),stat.st_mtime_ns,stat.st_size)+ if key in _ledger_cache:return _ledger_cache[key]+ # Read only the tail of the append-only ledger; never write it.+ with path.open('rb') as f:+  start=max(0,stat.st_size-2*1024*1024);f.seek(start)+  if start:f.readline()+  lines=f.readlines()+ events=[]+ candidates=[]+ for line in lines:+  try:e=json.loads(line)+  except ValueError:continue+  if e.get('event')!='artifact':continue+  file=e.get('file')+  if not file or pathlib.Path(file).suffix.lower() not in ['.png','.jpg','.jpeg']:continue+  candidates.append(e)+ for e in candidates[-12:]:+  file=e['file'];src=pathlib.Path(file);src=src if src.is_absolute() else run/src+  try:+   name=thumbnail(run,src)+   if not name:continue+   stamp=e.get('t') or e.get('at');unix=datetime.datetime.fromisoformat(stamp.replace('Z','+00:00')).timestamp()+   events.append({'step':e.get('step'),'label':e.get('caption') or e.get('kind','Saved artifact'),'thumb':name,'at':stamp,'unix':unix})+  except (OSError,ValueError,subprocess.SubprocessError):continue+ _ledger_cache.clear();_ledger_cache[key]=events[-12:];return events[-12:]++def data():+ c=cfg()+ if not c.get('enabled'):return {'enabled':False,'step':'Widget off','label':'Updates disabled','thumbs':[],'progress':None}+ run=pathlib.Path(c['run']);r=read(run/'run.json',{});w=read(run/'widget-state.json',{});step=r.get('currentStep','Starting')+ events=sorted(ledger_events(run)+w.get('events',[]),key=lambda e:e.get('unix',0));latest=events[-1] if events else {}+ visits=[v for group in r.get('steps',{}).values() for v in group.get('visits',[])]+ visit=max(visits,key=lambda v:v.get('at',''),default={});step_at=visit.get('at','');event_at=latest.get('at','')+ activity_step=latest.get('step') if event_at>step_at else step+ names=[s['name'] for s in r.get('flow',{}).get('steps',[]) if s.get('name')]+ completed=sum(bool(r.get('steps',{}).get(n,{}).get('visits')) and all(v.get('end') for v in r['steps'][n]['visits']) for n in names)+ progress=latest.get('progress') if event_at>step_at else None+ basis='activity progress' if progress is not None else 'completed planned steps; not time remaining'+ if progress is None and names and activity_step==step:progress=round(100*completed/len(names))+ age=max(0,time.time()-latest.get('unix',time.time()))+ return {'enabled':True,'step':activity_step or step,'ledgerStep':step,'label':latest.get('label') or 'Waiting for a saved artifact','progress':progress,'progressBasis':basis,'completedSteps':completed,'plannedSteps':len(names),'updatedAt':latest.get('at'),'staleSeconds':round(age),'thumbs':[{'url':'/thumb/'+e['thumb'],'label':e.get('label',''),'at':e.get('at'),'step':e.get('step')} for e in events[-3:] if e.get('thumb')],'run':str(run),'mode':'Reuses saved artifacts; no AI calls; extra capture disabled','delivered':bool(r.get('delivery'))}+PAGE='''<!doctype html><meta charset="utf-8"><style>*{box-sizing:border-box}html,body{margin:0;background:transparent;color:#dce7f4;font:11px system-ui;overflow:hidden;height:100%}#root{height:100%;display:flex;align-items:center;gap:5px;padding:0 3px}#pictures{display:flex;gap:2px;height:calc(100vh - 2px)}img{height:100%;width:auto;max-width:46px;object-fit:contain;border-radius:3px}#info{min-width:70px;max-width:160px;flex:1}#step,#label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#step{font-weight:650}#label{font-size:10px;color:#a9bbcf}#track{height:3px;background:#354455;margin-top:2px;border-radius:2px}#bar{height:100%;background:#59c6ee;max-width:100%;border-radius:2px}#mark{font-size:9px;color:#a9bbcf;white-space:nowrap}@media(max-height:33px){#info{display:flex;align-items:center;gap:5px;max-width:230px}#label{display:none}#track{width:35px;margin:0}#step{max-width:145px}#pictures img:not(:last-child){display:none}}</style><div id="root"><div id="pictures"></div><div id="info"><div id="step">AI Flow</div><div id="label">Waiting</div><div id="track"><div id="bar"></div></div></div><span id="mark"></span></div><script>let last={};function draw(d){last=d;document.querySelector('#step').textContent=d.step;document.querySelector('#label').textContent=d.label;document.querySelector('#bar').style.width=d.progress==null?'0%':d.progress+'%';document.querySelector('#mark').textContent=d.progress==null?'…':Math.round(d.progress)+'%';document.querySelector('#pictures').replaceChildren(...(d.thumbs||[]).map(t=>{let i=document.createElement('img');i.src=t.url;i.alt=t.label;i.title=t.label+' · '+t.at;return i}));document.querySelector('#root').title=d.label+' · '+(d.updatedAt||'no artifact yet')+' · '+(d.mode||'')+' · '+(d.progressBasis||'')+' · '+Math.round((d.staleSeconds||0)/60)+' min since thumbnail';window.parent.postMessage({hdWidget:{width:innerHeight>=34?265:250}},'*')}async function tick(){try{let r=await fetch('/data.json',{cache:'no-store'});draw(await r.json())}catch(e){document.querySelector('#label').textContent='Feed unavailable'}setTimeout(tick,document.hidden?30000:4000)}addEventListener('resize',()=>draw(last));tick()</script>'''+if a.action in ['enable','disable']:+ c=cfg();c['enabled']=a.action=='enable'+ if a.run:c['run']=str(a.run.resolve())+ if c['enabled']:assert c.get('run'),'--run required to enable';assert (pathlib.Path(c['run'])/'run.json').exists(),'run.json missing'+ c['port']=a.port;c['captureMode']='reuse-only';write(CONFIG,c);print(json.dumps(c))+elif a.action=='event':+ c=cfg();run=(a.run or pathlib.Path(c.get('run','.'))).resolve()+ if not c.get('enabled') or str(run)!=c.get('run'):print('Widget updates disabled for this run');sys_exit=0+ else:+  r=read(run/'run.json',{});e={'step':a.step or r.get('currentStep','working'),'label':a.label or 'Saved artifact','at':datetime.datetime.now(datetime.timezone.utc).isoformat(),'unix':time.time(),'progress':a.progress}+  if a.progress is not None:assert 0<=a.progress<=100,'progress must be 0..100'+  if a.file:+   src=a.file.resolve();assert src.is_relative_to(run) and src.is_file(),'artifact must exist inside this run';name=hashlib.sha256(src.read_bytes()).hexdigest()+'.png';dest=run/'widget-thumbs'/name;dest.parent.mkdir(exist_ok=True)+   if not dest.exists():subprocess.run(['ffmpeg','-y','-v','error','-i',str(src),'-vf','scale=256:192:force_original_aspect_ratio=decrease','-frames:v','1',str(dest)],check=True)+   e['thumb']=name+  state=read(run/'widget-state.json',{'events':[]});state['events']=(state['events']+[e])[-12:];write(run/'widget-state.json',state);print(json.dumps(e))+elif a.action=='status':print(json.dumps(data(),indent=2))+elif a.action=='stop':+ pidfile=pathlib.Path.home()/'.adom/aiflow-widget.pid'+ if pidfile.exists():+  pid=int(pidfile.read_text());cmd=pathlib.Path(f'/proc/{pid}/cmdline')+  if cmd.exists() and str(pathlib.Path(__file__).resolve()).encode() in cmd.read_bytes() and b'serve' in cmd.read_bytes():os.kill(pid,signal.SIGTERM)+  pidfile.unlink(missing_ok=True)+else:+ class H(http.server.BaseHTTPRequestHandler):+  def do_GET(self):+   if self.path in ['/','/index.html']:body=PAGE.encode();kind='text/html'+   elif self.path=='/health':body=b'OK';kind='text/plain'+   elif self.path=='/data.json':body=json.dumps(data()).encode();kind='application/json'+   elif self.path.startswith('/thumb/'):+    c=cfg();name=self.path.split('/')[-1]+    if not c.get('enabled') or not re.fullmatch(r'[0-9a-f]{64}\.png',name):self.send_error(404);return+    file=pathlib.Path(c['run'])/'widget-thumbs'/name+    if not file.is_file():self.send_error(404);return+    body=file.read_bytes();kind='image/png'+   else:self.send_error(404);return+   self.send_response(200);self.send_header('Content-Type',kind);self.send_header('Cache-Control','no-store');self.end_headers();self.wfile.write(body)+  def log_message(self,*args):pass+ server=http.server.ThreadingHTTPServer(('127.0.0.1',a.port),H);(pathlib.Path.home()/'.adom/aiflow-widget.pid').write_text(str(os.getpid()));print('AI Flow widget listening',a.port,flush=True);server.serve_forever()+--- a/tests/widget-workflow.py+++ b/tests/widget-workflow.py@@ -0,0 +1,15 @@+#!/usr/bin/env python3+import tempfile,pathlib,json,subprocess,os,hashlib+root=pathlib.Path(__file__).resolve().parents[1];helper=root/'tools/aiflow-widget.py'+with tempfile.TemporaryDirectory() as td:+ home=pathlib.Path(td);run=home/'run';run.mkdir();env=dict(os.environ,HOME=td);env['PATH']=str(home/'bin')+':'+env['PATH'];(home/'bin').mkdir();(home/'bin/adom-aiflow-widget').symlink_to(helper)+ def call(*args):return subprocess.check_output(['python3',str(helper),*args,'--run',str(run)],env=env,text=True)+ r={'currentStep':'placement','flow':{'steps':[{'name':'components'},{'name':'placement'}]},'steps':{'components':{'visits':[{'at':'2026-01-01T00:00:00Z','end':'2026-01-01T00:01:00Z'}]},'placement':{'visits':[{'at':'2026-01-01T00:01:00Z','end':None}]}}}+ (run/'run.json').write_text(json.dumps(r));ledger=run/'run.jsonl';ledger.write_text('');before=hashlib.sha256((run/'run.json').read_bytes()).hexdigest()+ assert 'disabled' in call('event','--label','ignored');assert not (run/'widget-state.json').exists()+ call('enable');d=json.loads(call('status'));assert d['progress']==50 and d['step']=='placement'+ call('event','--step','components','--label','5 of 40 reviewed','--progress','12.5');d=json.loads(call('status'));assert d['progress']==12.5 and d['step']=='components'+ old=(run/'widget-state.json').read_bytes();call('disable');call('event','--label','must not write');assert (run/'widget-state.json').read_bytes()==old;assert json.loads(call('status'))['thumbs']==[]+ assert hashlib.sha256((run/'run.json').read_bytes()).hexdigest()==before and ledger.read_text()==''+ print('PASS: opt-out prevents writes; enable selects run; planned-step progress; milestone override; ledger and run untouched')+--- a/hydrogen-widget.json+++ b/hydrogen-widget.json@@ -0,0 +1,23 @@+{+  "hydrogen_widget": "1.0.0",+  "id": "aiflow-progress",+  "name": "AI Flow progress",+  "icon": "mdi:progress-clock",+  "brief": "Current board-flow activity and timestamped artifact thumbnails.",+  "width": 250,+  "width_tall": 265,+  "install": "adom/adom-aiflow",+  "launch": {+    "command": "adom-aiflow-widget serve",+    "stop": "adom-aiflow-widget stop",+    "port": 27020+  },+  "health": {+    "url": "/",+    "timeout_seconds": 30+  },+  "about": "Opt-in. Reads the selected run and reuses saved thumbnails; no AI/provider calls. Extra captures are disabled.",+  "refresh": "Local JSON every 4 seconds while visible, 30 seconds while hidden.",+  "docs": "widgets/README.md"+}+--- a/widgets/README.md+++ b/widgets/README.md@@ -0,0 +1,22 @@+# AI Flow progress in Hydrogen++![Live Hydrogen widget with cached component thumbnails](https://wiki.adom.inc/api/pages/adom/esc-g431/repo/files/docs/astra/component-quality/widget-tall.png)++An optional title-bar widget shows the selected run's current step, a progress bar, and up to three recent artifact thumbnails. Enable it when the user wants to monitor a long board run while doing other work. Default is off; respect an explicit no-widget preference.++```+adom-aiflow --ai-thread "Your thread" --run /absolute/run widget enable+adom-aiflow --ai-thread "Your thread" --run /absolute/run widget event --step components --file /absolute/run/part.png --label "MPN-marked TPSM preview"+adom-aiflow --ai-thread "Your thread" --run /absolute/run widget disable+```++Open **AI Flow progress** in Hydrogen Widgets after enabling. A local checkout with `hydrogen-widget.json` is discoverable now; global listing needs the curated entry in adom/hydrogen-bootstrap after package publication.++The service reads `run.json` and existing image artifact events in `run.jsonl`. It never changes either file. Explicit milestone events go in `widget-state.json`; thumbnails are cached by content hash in `widget-thumbs/`. Images must be inside the selected run. Publish existing component, marked-model, symbol, placement, pour and Fields images when those milestones produce them. No extra capture or model call is made. The display uses the new step immediately and retains older thumbnails with their timestamps until fresh images arrive.++The percentage is completed planned steps, not a prediction of elapsed or remaining time. An explicit milestone percentage can describe a component batch; label that scope. Returns can reduce progress. If no denominator exists, the widget shows an unknown value. The tooltip identifies the basis and thumbnail age.++The full 38-pixel title bar shows three thumbnails and two text lines; the compact 20-pixel bar shows the latest thumbnail and step. The local JSON feed polls every four seconds while visible, every thirty while hidden. Disabled means no ledger scans, thumbnail production or event writes; the mounted slot says updates are disabled. Closing the widget also stops its own server via a verified PID, without touching another process.++Files: `hydrogen-widget.json`, `tools/aiflow-widget.py`, this page, and the helper symlink installed by `install.sh`. Python 3 serves it; ffmpeg downsizes existing images. No provider access, AI tokens or network analysis is involved. See the [Hydrogen widget SDK](https://wiki.adom.inc/adom/hydrogen-bootstrap).+--- a/install.sh+++ b/install.sh@@ -1,13 +1,18 @@⋯ 10 unchanged lines ⋯ command -v service-kicad >/dev/null 2>&1 || echo "Hint: the offline gate needs service-kicad (adom-wiki pkg install adom/service-kicad)" command -v adom-bridge >/dev/null 2>&1 || echo "Hint: the live stages need adom-bridge (the Adom Bridge CLI) and the KiCad Bridge on a desktop" echo "OK: adom-aiflow installed at ~/.local/bin/adom-aiflow. Run 'adom-aiflow --version'."++# Retain the helper beside its package so the scoped stop can verify its PID.+chmod +x "$HERE/tools/aiflow-widget.py"+ln -sf "$HERE/tools/aiflow-widget.py" "$HOME/.local/bin/adom-aiflow-widget"+--- a/tools/library-tour.py+++ b/tools/library-tour.py@@ -0,0 +1,53 @@+#!/usr/bin/env python3+"""Build/serve an actual moving 3D library tour; keep detail separate from final overview."""+import argparse,pathlib,json,hashlib,shutil,http.server,subprocess,urllib.request,time,os,sys,struct+p=argparse.ArgumentParser();p.add_argument('action',choices=['build','serve','record','verify']);p.add_argument('--ai-thread');p.add_argument('--run',type=pathlib.Path);p.add_argument('--manifest',type=pathlib.Path);p.add_argument('--out',type=pathlib.Path,required=True);p.add_argument('--port',type=int,default=8878);a=p.parse_args();a.out=a.out.resolve()+def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest()+if a.action=='build':+ if not a.manifest:p.error('--manifest is required for build')+ src=json.loads(a.manifest.read_text());assert src.get('eda') in ['kicad','altium','fusion'],'explicit target EDA required';assert src.get('mpnMarking') in ['on','off'],'resolve the marking preference before building the selected library'+ rows=src.get('components',[]);assert rows,'empty component library';seen=set();a.out.mkdir(parents=True,exist_ok=True);(a.out/'models').mkdir(exist_ok=True);result=[]+ for r in rows:+  assert r['mpn'] not in seen,'duplicate component identity';seen.add(r['mpn']);selected=r['selectedModel'];expected='mpn' if src['mpnMarking']=='on' else 'plain';assert selected['variant']==expected,f"{r['mpn']}: tour/board variant mismatch; no silent plain fallback"+  for k in ['step','glb']:+   f=pathlib.Path(selected[k]);f=f if f.is_absolute() else a.manifest.parent/f;assert f.is_file() and f.stat().st_size>0,f'missing {k}: {r["mpn"]}';assert digest(f)==selected[k+'Sha256'],f'stale {k} hash: {r["mpn"]}'+  raw=f.read_bytes();assert raw[:4]==b'glTF' and struct.unpack_from('<I',raw,8)[0]==len(raw) and raw[16:20]==b'JSON',f'invalid GLB bytes: {r["mpn"]}'+  assert r.get('wiki') and r.get('provenance'),'wiki/provenance link required';glb=pathlib.Path(selected['glb']);glb=glb if glb.is_absolute() else a.manifest.parent/glb;name=digest(glb)+'.glb';shutil.copy2(glb,a.out/'models'/name);result.append({'mpn':r['mpn'],'wiki':r['wiki'],'provenance':r['provenance'],'glb':'models/'+name,'variant':expected,'stepSha256':selected['stepSha256'],'glbSha256':selected['glbSha256']})+ obj={'schemaVersion':1,'eda':src['eda'],'mpnMarking':src['mpnMarking'],'components':result,'secondsPerComponent':2,'projectUrl':src.get('projectUrl'),'selectionSha256':digest(a.manifest),'nativeLibraryEvidence':src.get('nativeLibraryEvidence'),'finalVideoPolicy':{'overviewSeconds':5,'detailIsSeparate':True,'finalMaximumSeconds':120,'wikiScrollSnippets':'optional; separate from default overview'}};(a.out/'tour.json').write_text(json.dumps(obj,indent=2));shutil.copy2(pathlib.Path(__file__).with_suffix('.html'),a.out/'index.html');print(f'Built {len(rows)}-component tour for {src["eda"]}; selected variants {src["mpnMarking"]}; this does not certify native library installation.')+elif a.action=='serve':+ assert (a.out/'tour.json').exists(),'build first'+ class Handler(http.server.SimpleHTTPRequestHandler):+  def __init__(self,*args,**kwargs):super().__init__(*args,directory=str(a.out),**kwargs)+  def do_POST(self):+   if self.path not in ['/recordings/detail.webm','/recordings/overview.webm']:self.send_error(404);return+   size=int(self.headers.get('Content-Length','0'))+   if size<=0 or size>256*1024*1024:self.send_error(413);return+   dest=a.out/self.path.lstrip('/');dest.parent.mkdir(exist_ok=True);dest.write_bytes(self.rfile.read(size));self.send_response(200);self.end_headers();self.wfile.write(b'OK')+  def log_message(self,*args):pass+ print(f'Library tour serving on http://localhost:{a.port}/',flush=True);http.server.ThreadingHTTPServer(('127.0.0.1',a.port),Handler).serve_forever()+elif a.action=='record':+ control=pathlib.Path.home()/'.adom/hydrogen-control-url';assert control.exists(),'Hydrogen control unavailable; open the tour in Pup and use the recording controls, then verify';base=control.read_text().strip();url=f'http://localhost:{a.port}/';subprocess.run(['adom-cli','hydrogen','webview','open-or-refresh','--name','Component library tour','--url',url],check=True)+ def evaluate(js):+  q={'target':url,'js':js};r=json.load(urllib.request.urlopen(urllib.request.Request(base+'/eval-in',data=json.dumps(q).encode(),headers={'Content-Type':'application/json'}),timeout=20));assert r.get('ok'),r;return r.get('value')+ deadline=time.monotonic()+180+ while time.monotonic()<deadline:+  state=evaluate('window.libraryTour?.stats?.() || {ready:false,error:window.libraryTour?.error}')+  if state and state.get('error'):raise RuntimeError(state)+  if state and state.get('ready'):break+  time.sleep(2)+ else:raise RuntimeError('Tour did not load; no recording accepted')+ for kind in ['detail','overview']:+  target=a.out/'recordings'/(kind+'.webm');old=target.stat().st_mtime_ns if target.exists() else 0;evaluate(f"void window.libraryTour.record({json.dumps(kind)})");deadline=time.monotonic()+max(60,len(json.load(open(a.out/'tour.json'))['components'])*2+60)+  while time.monotonic()<deadline:+   time.sleep(2);state=evaluate('window.libraryTour.stats()')+   if state.get('error'):raise RuntimeError(state)+   if target.exists() and target.stat().st_mtime_ns!=old and not state['recording']:break+  else:raise RuntimeError('Recording did not complete')+  print('Recorded',kind,flush=True)+ subprocess.run([sys.executable,__file__,'verify','--out',str(a.out)],check=True)+elif a.action=='verify':+ src=json.load(open(a.out/'tour.json'));clips=[]+ for kind in ['detail','overview']:+  raw=a.out/'recordings'/(kind+'.webm');assert raw.exists(),f'missing {kind} recording';mp4=raw.with_suffix('.mp4');subprocess.run(['ffmpeg','-y','-v','error','-i',str(raw),'-an','-vf','fps=24,scale=1280:720','-c:v','libx264','-preset','veryfast','-crf','28','-pix_fmt','yuv420p','-movflags','+faststart',str(mp4)],check=True);meta=json.loads(subprocess.check_output(['ffprobe','-v','error','-show_format','-show_streams','-of','json',str(mp4)]));seconds=float(meta['format']['duration']);assert mp4.stat().st_size<10_000_000,'clip exceeds page cap; reduce bitrate before publishing';assert int(meta['streams'][0]['nb_frames'])<=seconds*25+2,'unexpected frame duplication';expected=5 if kind=='overview' else 2*len(src['components']);assert abs(seconds-expected)<3,f'{kind}: wrong duration {seconds}';sheet=raw.with_name(kind+'-sheet.png');subprocess.run(['ffmpeg','-y','-v','error','-i',str(mp4),'-vf',f'fps=1/{max(seconds/12,.1)},scale=320:-2,tile=4x3','-frames:v','1',str(sheet)],check=True);clips.append({'kind':kind,'raw':str(raw),'file':str(mp4),'seconds':seconds,'sha256':digest(mp4),'contactSheet':str(sheet),'visualReview':'pending','useInFinal':kind=='overview'})+ (a.out/'clips.json').write_text(json.dumps({'selectionSha256':src['selectionSha256'],'mpnMarking':src['mpnMarking'],'clips':clips,'finalMaximumSeconds':120,'notes':'Review both contact sheets and playback before registration; only the overview belongs in the final cut by default.'},indent=2));print(json.dumps(clips,indent=2))+--- a/tools/library-tour.html+++ b/tools/library-tour.html@@ -0,0 +1,33 @@+<!doctype html><meta charset="utf-8"><title>Component library tour</title>+<link rel="stylesheet" href="https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/style.css">+<style>body{margin:0;background:#151b23;color:#e9eff8;font:15px system-ui}header{padding:14px 20px;display:flex;gap:15px;align-items:center}button,select,a{background:#263647;color:#dcefff;border:1px solid #4e647d;border-radius:5px;padding:8px;text-decoration:none}#stage{width:100%;aspect-ratio:16/9;position:relative;max-height:85vh}#status{color:#b6c5d7}#error{color:#ffb09a;padding:10px}#viewer{position:absolute;inset:0}canvas{outline:none}small{padding:0 20px;display:block;color:#b6c5d7}</style>+<header><b>Component library</b><button id="overview">All components</button><button id="detail">Record full tour</button><button id="short">Record 5s overview</button><select id="pick"></select><a id="wiki" target="_blank">Component wiki</a><span id="status">Loading…</span></header><small id="policy"></small><div id="stage"><div id="viewer"></div></div><div id="error"></div>+<script type="module">+import 'https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js?v=20260915a';+const B=window.BABYLON,V=window.Adom3DViewerBabylon9;const state=window.libraryTour={ready:false,recording:false,error:null,mode:'overview',completed:[]};const status=document.querySelector('#status');+try{+ const data=await(await fetch('tour.json')).json();state.manifest=data;document.querySelector('#policy').textContent=`${data.components.length} reusable components · MPN marking ${data.mpnMarking} · Preview sizes normalized for inspection; original CAD is unchanged. Detailed tour is separate; the final video uses the 5s overview.`;+ const viewer=V.init(document.querySelector('#viewer'),{zUp:true,showGround:false,showViewCube:false});window.tourViewer=viewer;await viewer.loadModel(data.components[0].glb);const scene=viewer.getScene(),engine=viewer.getEngine(),camera=viewer.getCamera();viewer.clearScene();camera.lowerRadiusLimit=.1;camera.upperRadiusLimit=200;camera.minZ=.01;camera.maxZ=500;const groups=[];+ for(let i=0;i<data.components.length;i++){+  const item=data.components[i];status.textContent=`Loading ${i+1}/${data.components.length}: ${item.mpn}`;const imported=await B.SceneLoader.ImportMeshAsync('',item.glb,'',scene);const group=new B.TransformNode('component_'+i,scene),content=new B.TransformNode('geometry_'+i,scene);content.parent=group;+  let lo=new B.Vector3(Infinity,Infinity,Infinity),hi=new B.Vector3(-Infinity,-Infinity,-Infinity);for(const m of imported.meshes){m.computeWorldMatrix(true);if(m.getTotalVertices()>0){const z=m.getBoundingInfo().boundingBox;lo=B.Vector3.Minimize(lo,z.minimumWorld);hi=B.Vector3.Maximize(hi,z.maximumWorld);}}+  const dims=hi.subtract(lo),span=Math.max(dims.x,dims.y,dims.z);if(!Number.isFinite(span)||span<=0)throw Error('No finite geometry: '+item.mpn);const scale=2.6/span,center=lo.add(hi).scale(.5);for(const m of imported.meshes)if(!m.parent)m.parent=content;content.scaling.setAll(scale);content.position=center.scale(-scale);+  const x=(i%8-3.5)*4.6,y=(Math.floor(i/8)-(Math.ceil(data.components.length/8)-1)/2)*4.3;group.position.set(x,y,0);+  const label=B.MeshBuilder.CreatePlane('label_'+i,{width:4.2,height:.45},scene);label.parent=group;label.position.set(0,-1.85,.1);label.billboardMode=B.Mesh.BILLBOARDMODE_ALL;const tex=new B.DynamicTexture('text_'+i,{width:768,height:80},scene,false);tex.hasAlpha=true;tex.drawText(item.mpn,null,55,'bold 38px sans-serif','#eff6ff','transparent',true);const mat=new B.StandardMaterial('labelmat_'+i,scene);mat.diffuseTexture=tex;mat.emissiveColor=B.Color3.White();mat.disableLighting=true;mat.backFaceCulling=false;label.material=mat;+  groups.push({group,content,item,label,base:content.position.clone()});const option=document.createElement('option');option.value=i;option.textContent=item.mpn;document.querySelector('#pick').appendChild(option);+ }+ // Rotate around the part centre, not around the source CAD origin.+ for(const g of groups){const pivot=new B.TransformNode('spin_'+g.item.mpn,scene);pivot.parent=g.group;g.content.parent=pivot;g.pivot=pivot;}+ const center=new B.Vector3(0,0,0);function all(){state.mode='overview';groups.forEach(g=>g.group.setEnabled(true));camera.setTarget(center);camera.setPosition(new B.Vector3(0,-13,40));document.querySelector('#wiki').href=data.projectUrl;status.textContent='All '+groups.length+' components';}+ function one(i){state.mode='detail';state.index=i;groups.forEach((g,j)=>g.group.setEnabled(i===j));const g=groups[i];camera.setTarget(g.group.position);camera.setPosition(g.group.position.add(new B.Vector3(4,-6,5)));document.querySelector('#wiki').href=g.item.wiki;document.querySelector('#pick').value=i;status.textContent=`${i+1}/${groups.length} · ${g.item.mpn}`;}+ state.all=all;state.one=one;state.stats=()=>({ready:state.ready,recording:state.recording,mode:state.mode,components:groups.length,meshes:scene.meshes.length,vertices:scene.getTotalVertices(),frames:state.frames||0,error:state.error});+ let previous=performance.now();scene.onBeforeRenderObservable.add(()=>{let now=performance.now(),dt=Math.min((now-previous)/1000,.1);previous=now;state.frames=(state.frames||0)+1;for(const g of groups)if(g.group.isEnabled())g.pivot.rotation.z+=dt*(state.mode==='detail'?.7:.24);});+ const sleep=ms=>new Promise(r=>setTimeout(r,ms));state.record=async function(kind){if(state.recording)throw Error('Already recording');state.recording=true;state.error=null;try{+  if(document.visibilityState!=='visible')throw Error('Tour tab is hidden; make it visible before recording');engine.setSize(1280,720);kind==='detail'?one(0):all();await sleep(350);const canvas=engine.getRenderingCanvas();const mime=['video/webm;codecs=vp9','video/webm;codecs=vp8'].find(x=>MediaRecorder.isTypeSupported(x));if(!mime)throw Error('No supported WebM recorder');const stream=canvas.captureStream(30),chunks=[],rec=new MediaRecorder(stream,{mimeType:mime,videoBitsPerSecond:1200000});rec.ondataavailable=e=>{if(e.data.size)chunks.push(e.data)};let stopped=new Promise(r=>rec.onstop=r);rec.start(500);+  if(kind==='detail'){for(let i=0;i<groups.length;i++){one(i);await sleep(data.secondsPerComponent*1000);}}else await sleep(5000);+  rec.stop();await stopped;stream.getTracks().forEach(t=>t.stop());const blob=new Blob(chunks,{type:mime});let response=await fetch('/recordings/'+kind+'.webm',{method:'POST',body:blob});if(!response.ok)throw Error(await response.text());state.completed.push(kind);status.textContent=`Saved ${kind} recording`;all();+ }catch(e){state.error=String(e);document.querySelector('#error').textContent=String(e);}finally{state.recording=false;}};+ document.querySelector('#overview').onclick=all;document.querySelector('#pick').onchange=e=>one(+e.target.value);document.querySelector('#detail').onclick=()=>state.record('detail');document.querySelector('#short').onclick=()=>state.record('overview');state.ready=true;all();+}catch(e){state.error=String(e);document.querySelector('#error').textContent=String(e);status.textContent='Failed';}+</script>+--- a/docs/library-tours.md+++ b/docs/library-tours.md@@ -0,0 +1,12 @@+# Component library tours++Review the library early, before placement, in the actual EDA selected by the user. Match symbols, footprints and model paths in that EDA; a GLB gallery alone is not native library validation.++Use one reviewed manifest (`library.json`) to select every component's exact STEP and GLB hashes and its plain or optional MPN-marked variant. If marking is on, a missing marked asset must be resolved or the user must explicitly change that choice. Never silently show plain models in the tour when the board is expected to use marked ones. Preserve the original package frame, and fit marking along the longest usable top-face direction, with pin-1 clearance.++The additive `tools/library-tour.py` helper builds an interactive Adom 3D viewer from that manifest, then records two distinct clips: a two-second orbit per component and a five-second moving overview. Preview bodies are normalized in size for visibility; source CAD is unchanged. Keep the long walkthrough as a separate review artifact. The final video should include only the short overview by default and remain at most two minutes. Optional brief scrolling component wiki shots belong in a separate library tour when a large BOM would otherwise dominate the final cut.++`build --manifest library.json --out library-tour`, `serve --out library-tour --port 8878`, `record --out library-tour --port 8878`, then inspect both contact sheets and play the clips. Supply your run and thread to the helper. Hash/GLB validation and successful loading are not enough: reject blank, clipped, wrong-variant or motionless clips. `clips.json` deliberately leaves visual review pending. Register approved clips only after native EDA binding and visible review.++Release integration note: the published source snapshot lacks the installed release's compose and tour implementations (issue #16). This helper is independently runnable; wiring its approved overview into released compose must occur in the complete maintained release tree. Do not replace the installed production binary with the incomplete snapshot or claim that integration is already released.+

Comments

No comments yet.

Log in to comment.