diff --git a/docs-site/components/cookbook/ComponentPlan.tsx b/docs-site/components/cookbook/ComponentPlan.tsx deleted file mode 100644 index c4be21ca9..000000000 --- a/docs-site/components/cookbook/ComponentPlan.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useState } from "react"; - -/** A UI experiment, deliberately separated from the serving schema and generated YAML. */ -export default function ComponentPlan({ onDraftChange }: { onDraftChange?: (draft: boolean) => void }) { - const [selection, setSelection] = useState>({}); - const groups = [ - { name: "Weights", values: ["Recipe default", "FP8", "NVFP4"] }, - { name: "Text encoder", values: ["Recipe default", "NVFP4 encoder"] }, - { name: "Video decoder", values: ["Recipe default", "FP16 VAE", "NVFP4 VAE"] }, - ]; - return ( -
- - Explore components Design preview - -
- {groups.map((group) => ( -
- {group.name} -
- {group.values.map((value) => ( - - ))} -
-
- ))} -
-

- Preview only. Alternate components need a compatible recipe; some releases are planned. These choices do not - change YAML. -

- {Object.values(selection).some((value) => value !== "Recipe default") && ( - - )} -
- ); -} diff --git a/docs-site/components/cookbook/HardwarePicker.tsx b/docs-site/components/cookbook/HardwarePicker.tsx index e012d0489..6495ab5c6 100644 --- a/docs-site/components/cookbook/HardwarePicker.tsx +++ b/docs-site/components/cookbook/HardwarePicker.tsx @@ -1,16 +1,20 @@ import { useEffect, useId, useState } from "react"; import { Input } from "@/components/ui/input"; +type Purpose = "target" | "filter"; + export default function HardwarePicker({ backend, options, value, onChange, + purpose = "target", }: { backend: string; options: string[][]; value: string; onChange: (value: string) => void; + purpose?: Purpose; }) { const [search, setSearch] = useState(""); const [adding, setAdding] = useState(false); @@ -21,6 +25,8 @@ export default function HardwarePicker({ }, []); const id = useId(); const known = options.filter((option) => option.join(" ").toLowerCase().includes(search.toLowerCase())); + const selected = value.startsWith("custom:") ? value.slice(7) : options.find(([key]) => key === value)?.[1]; + const savedUnknown = !!value && !selected; function add() { const name = search.trim(); if (!name) return; @@ -29,10 +35,15 @@ export default function HardwarePicker({ setSearch(""); setAdding(false); } + function clear() { + setCustom(""); + setSearch(""); + onChange(""); + } return (
- Hardware target + Hardware {purpose} {known.map(([key, title, detail]) => (
- +
+ + {(selected || savedUnknown) && ( + + )} +
+ {!selected && ( +

+ {savedUnknown ? "Saved hardware target is not in this list." : `No hardware ${purpose} selected.`} +

+ )} {adding && (
-

Search the list or add your device. Memory lookup and fit checks are not connected yet.

+

+ {purpose === "filter" + ? "Saved in this URL and used to filter by runtime. Fit checks appear only when an estimate covers the device." + : "Saved in this URL as your setup target. Device choice does not change generated configuration."} +

diff --git a/docs-site/components/cookbook/RecipeFacts.tsx b/docs-site/components/cookbook/RecipeFacts.tsx new file mode 100644 index 000000000..cd40c7a67 --- /dev/null +++ b/docs-site/components/cookbook/RecipeFacts.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; + +export type Validity = "valid" | "invalid" | "loading"; +export type Fit = "fits" | "tight" | "exceeds" | "unsupported" | "unknown"; +export type Evidence = "recorded" | "none" | "stale"; + +const validityLabel: Record = { + valid: "Configuration valid", + invalid: "Configuration invalid", + loading: "Checking configuration", +}; +const fitLabel: Record = { + fits: "Fits estimate", + tight: "Tight fit", + exceeds: "Likely exceeds memory", + unsupported: "Unsupported combination", + unknown: "Fit unknown", +}; +const evidenceLabel: Record = { + recorded: "Run evidence available", + none: "No recorded run", + stale: "Run evidence does not match these settings", +}; + +/** + * Three separate facts: the recipe schema accepts the values, a memory estimate + * covers the configuration, and a recorded run matches it. No positive claim is + * made until the shared API supplies the corresponding fact. + */ +export default function RecipeFacts({ + validity, + fit = "unknown", + evidence = "none", + subject = "configuration", + detail, + validityText, + validityDetail, +}: { + validity: Validity; + fit?: Fit; + evidence?: Evidence; + subject?: "configuration" | "example"; + detail?: string; + validityText?: string; + validityDetail?: string; +}) { + const [open, setOpen] = useState(false); + const id = `recipe-facts-${validity}-${fit}-${evidence}`; + return ( +
+
+ + {validity === "valid" ? (validityText ?? `Valid ${subject}`) : validityLabel[validity]} + + {fitLabel[fit]} + {evidenceLabel[evidence]} +
+ + {open && ( +
+

+ {validityLabel[validity]}.{" "} + {validityDetail ?? + (validity === "valid" + ? "Selected values pass the recipe schema and runtime topology checks." + : validity === "invalid" + ? "Output is withheld until the highlighted fields are corrected." + : "Values are still being checked.")} +

+

+ {fitLabel[fit]}.{" "} + {fit === "unknown" + ? "No memory estimate covers this deployment, device, and request yet. This is not a fit claim." + : "The shared capacity API supplied this estimate; it is not a measured guarantee."} +

+

+ {evidenceLabel[evidence]}.{" "} + {evidence === "none" + ? "Successful runs on other checkpoints, settings, software revisions, or GPU layouts do not establish this configuration." + : evidence === "stale" + ? "A recorded run exists, but edited settings fall outside what it covers." + : "A recorded run matches the deployment and settings shown."} +

+ {detail &&

{detail}

} +
+ )} +
+ ); +} diff --git a/docs-site/islands/Cookbook.tsx b/docs-site/islands/Cookbook.tsx index 92ef9f2a4..1aeb8febb 100644 --- a/docs-site/islands/Cookbook.tsx +++ b/docs-site/islands/Cookbook.tsx @@ -6,9 +6,9 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import SourceRecipe from "./SourceRecipe"; -import ComponentPlan from "@/components/cookbook/ComponentPlan"; import HardwarePicker from "@/components/cookbook/HardwarePicker"; import ModelHeader from "@/components/cookbook/ModelHeader"; +import RecipeFacts from "@/components/cookbook/RecipeFacts"; import { configAndLaunch, inferenceOutput, sdkCommand } from "@/lib/cookbook-commands"; import { api, @@ -193,18 +193,16 @@ function Editor({ selectRuntime: (id: string) => void; }) { const [advanced, setAdvanced] = useState(false); - const [componentDraft, setComponentDraft] = useState(false); const [runMode, setRunMode] = useState("serving"); const [sourceImage, setSourceImage] = useState(""); const sourceImageId = useId(); const [showEnv, setShowEnv] = useState(false); const [showClient, setShowClient] = useState(false); const [outputTab, setOutputTab] = useState("commands"); - const [hardware, setHardware] = useState( - () => - new URL(window.location.href).searchParams.get("hardware") ?? - (catalog.id.startsWith("fasth3-trim/") ? "spark" : "unspecified"), - ); + const [hardware, setHardware] = useState(() => { + const requested = new URL(window.location.href).searchParams.get("hardware"); + return requested && requested !== "unspecified" ? requested : ""; + }); const [edits, setEdits] = useState>({}); const [parseErrors, setParseErrors] = useState>({}); const [reset, setReset] = useState(0); @@ -214,8 +212,6 @@ function Editor({ let failure = ""; let failedPath: string | undefined; try { - if (componentDraft) - throw new Error("Component preview selected. A compatible serving recipe is needed before generating YAML."); if (Object.keys(parseErrors).length) throw new Error("Correct the highlighted fields to generate a configuration."); result = api.resolveConfig(catalog, edits); output = @@ -247,10 +243,10 @@ function Editor({ setEdits(next); setParseErrors(errors); } - async function copy(text: string) { + async function copy(text: string, message = "Copied to clipboard.") { try { await navigator.clipboard.writeText(text); - setNotice("Copied to clipboard."); + setNotice(message); } catch { setNotice("Copy failed. Select and copy the text below."); } @@ -280,7 +276,8 @@ function Editor({ ["spark", "DGX Spark", "GB10"], ] ).filter(([id]) => !nvfp4 || id !== "h100"); - const selectedHardware = hardwareOptions.find(([value]) => value === hardware) ?? hardwareOptions[0]; + const selectedHardware = hardwareOptions.find(([value]) => value === hardware); + const hardwareLabel = hardware.startsWith("custom:") ? hardware.slice(7) : selectedHardware?.[1]; const model = modelDirectory.find((model) => model.id === catalog.id.split("/")[0])!; const renderField = (field: Field) => ( group === "server" || group.includes("parallelism") || group.startsWith("streaming"), ); @@ -309,7 +313,11 @@ function Editor({ model={model} actions={ <> - {runMode === "serving" && ( )} {Object.keys(catalog.env).length > 0 && ( )}
@@ -404,7 +414,8 @@ function Editor({ {launchCommand}

- Includes your settings and writes config.yaml. Run from an installed FastVideo checkout. + Uses your settings; Copy config + command writes config.yaml first. Run from an installed FastVideo + checkout.

{runMode === "serving" && (
setShowClient(event.currentTarget.open)} > {result.websocketUrl ? "Health check and WebSocket" : "Example request"} -
                       {result.clientCommand}
@@ -479,7 +490,7 @@ function Editor({
             
             
@@ -490,7 +501,7 @@ function Editor({ href={sitePath( mlx ? "/getting_started/installation/mlx" - : selectedHardware[0] === "spark" + : hardware === "spark" ? "/getting_started/installation/spark" : "/getting_started/installation/gpu", )} @@ -499,7 +510,14 @@ function Editor({
-

Hardware fit has not been verified. GPU count updates the YAML.

+ {hardwareLabel ? ( +

+ {mlx + ? `${hardwareLabel} is saved as your target. Apple Silicon uses unified memory; dedicated GPU memory does not apply.` + : `${hardwareLabel} is saved as your target. It does not change the generated YAML; use GPU count and parallelism for the run.`} +

+ ) : null} + {catalog.workload === "i2v" && runMode !== "serving" && ( @@ -538,7 +556,9 @@ function Editor({ )} )} - +

+ Components come from the maintained recipe. They are not individually selectable in the cookbook yet. +

)} {standardGroups.map((group) => ( @@ -570,7 +590,6 @@ function Editor({ setParseErrors( Object.fromEntries(Object.entries(parseErrors).filter(([path]) => !path.includes("parallelism"))), ); - setComponentDraft(false); setNotice("Parallelism set to automatic."); }} > @@ -588,7 +607,6 @@ function Editor({ onClick={() => { setEdits({}); setSourceImage(""); - setComponentDraft(false); setParseErrors({}); setReset(reset + 1); setNotice("Restored the baseline configuration."); diff --git a/docs-site/islands/SourceRecipe.tsx b/docs-site/islands/SourceRecipe.tsx index f9e3411d9..f100ed74c 100644 --- a/docs-site/islands/SourceRecipe.tsx +++ b/docs-site/islands/SourceRecipe.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import ModelHeader from "@/components/cookbook/ModelHeader"; import HardwarePicker from "@/components/cookbook/HardwarePicker"; +import RecipeFacts from "@/components/cookbook/RecipeFacts"; import { configAndLaunch, fileAndLaunch, sdkCommand, pythonSource } from "@/lib/cookbook-commands"; import { sitePath, type DirectoryModel } from "@/lib/cookbook-pages"; import { api } from "@/lib/cookbook"; @@ -50,9 +51,13 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) { const [outputTab, setOutputTab] = useState("commands"); const [error, setError] = useState(""); const [edits, setEdits] = useState>({}); - const [hardware, setHardware] = useState("unspecified"); + const [hardware, setHardware] = useState(() => { + const requested = new URL(window.location.href).searchParams.get("hardware"); + return requested && requested !== "unspecified" ? requested : ""; + }); useEffect(() => { - setHardware(new URL(window.location.href).searchParams.get("hardware") ?? "unspecified"); + const requested = new URL(window.location.href).searchParams.get("hardware"); + setHardware(requested && requested !== "unspecified" ? requested : ""); }, []); const recipe = model.recipes.find((recipe) => recipe.id === selected)!; const yamlSource = @@ -115,14 +120,15 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) { return () => controller.abort(); }, [sourceUrl, yamlSource, recipe]); function selectMode(next: string) { + const reset = (next === "serving") !== (mode === "serving"); setMode(next); - if ((next === "serving") !== (mode === "serving")) setEdits({}); - setNotice(""); + if (reset) setEdits({}); + setNotice(reset ? "Serving uses a different native example, so generation edits were reset." : ""); } - async function copy(value: string) { + async function copy(value: string, message = "Copied to clipboard.") { try { await navigator.clipboard.writeText(value); - setNotice("Copied to clipboard."); + setNotice(message); } catch { setNotice("Copy failed. Select and copy the text below."); } @@ -140,14 +146,22 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) { ) failure = "Enter valid numeric settings before copying the example."; } + let config: Record | undefined; if (example && yamlSource) { - const config = parse(source); - for (const [key, value] of Object.entries(edits)) { - const path = example.controls[Number(key)].path!; - const parent = path.slice(0, -1).reduce((item, name) => item[name], config); - parent[path.at(-1)!] = value; + try { + const root = parse(source) as Record; + config = root; + for (const [key, value] of Object.entries(edits)) { + const path = example.controls[Number(key)].path!; + const parent = path + .slice(0, -1) + .reduce>((item, name) => item[name] as Record, root); + parent[path.at(-1)!] = value; + } + source = stringify(root); + } catch { + failure = "The checked-in YAML could not be parsed, so runnable output is withheld."; } - source = stringify(config); } else if (example) { source = pythonSource(source, example.controls, edits); } @@ -169,19 +183,24 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) { }) .join(""); const sdk = mode === "sdk" && !!yamlSource; - const config = sdk && source ? parse(source) : undefined; - const displayedSource = config - ? stringify({ generator: config.generator, request: config.request ?? config.default_request }) - : source; + // The SDK consumes only generator + request; serving and CLI keep server and every native field. + const displayedSource = + config && sdk + ? stringify({ + generator: config.generator as Record, + request: config.request ?? config.default_request, + }) + : source; const launch = sdk ? command.replace(/fastvideo generate --config config\.yaml$/, sdkCommand) : command; const ready = !!example && !failure; + const workload = recipe.task.toLowerCase().includes("image") ? "i2v" : "t2v"; const clientCommand = mode === "serving" && ready ? api.sampleRequest( { runtime: { interface: "rest", server_defaults: { host: "127.0.0.1", port: 8000 } }, model: { id: model.model }, - workload: "t2v", + workload, }, parse(source), ).command @@ -202,10 +221,14 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) { ["rtx", "RTX PRO 6000", "Workstation"], ["spark", "DGX Spark", "GB10"], ]; + const hardwareLabel = hardware.startsWith("custom:") + ? hardware.slice(7) + : devices.find(([id]) => id === hardware)?.[1]; const chooseHardware = (value: string) => { setHardware(value); const url = new URL(window.location.href); - url.searchParams.set("hardware", value); + if (value) url.searchParams.set("hardware", value); + else url.searchParams.delete("hardware"); window.history.replaceState(null, "", url); }; const control = (field: Control, index: number) => { @@ -276,7 +299,11 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) { model={model} actions={ <> - {mode === "serving" && ( )} {environment && ( )} @@ -352,7 +387,10 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
               {launch}
             
-

Includes your settings. Run from an installed FastVideo checkout.

+

+ Uses your settings; the copy action includes the source file and launch command. Run from an installed + FastVideo checkout. +

{clientCommand && (
setShowClient(event.currentTarget.open)} > Example request -
                   {clientCommand}
@@ -415,13 +453,13 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
                   aria-pressed={platform === (mlx ? "MLX" : "CUDA")}
                   onClick={() => {
                     if (platform === (mlx ? "MLX" : "CUDA")) return;
-                    setNotice("");
                     const target = model.recipes.find((entry) =>
                       entry.gpu_types.includes(platform === "MLX" ? "Apple Silicon" : "NVIDIA"),
                     )!;
                     setSelected(target.id);
                     setMode("sdk");
                     setEdits({});
+                    setNotice(`Switched to the ${platform} recipe; generation edits were reset.`);
                   }}
                 >
                   {platform}
@@ -432,11 +470,7 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
                id === hardware)?.[0] ?? devices[0][0])
-                }
+                value={hardware}
                 onChange={chooseHardware}
               />
               
@@ -445,11 +479,26 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) { {!example?.controls.some((field) => field.name === "num_gpus") && (

{mlx ? "1 Apple Silicon host" : `${recipe.hardware?.gpu_count ?? 1} GPU in the source example`}

)} + + Setup guide ↗ +
-

- Hardware fit has not been verified. Settings edit the source example; device choice is a preference. -

+ {hardwareLabel ? ( +

+ {mlx + ? `${hardwareLabel} is saved as your target. Apple Silicon uses unified memory; dedicated GPU memory does not apply.` + : `${hardwareLabel} is saved as your target. It does not change the example; use GPU count in the source settings.`} +

+ ) : null} + {model.recipes.filter((entry) => entry.gpu_types.includes(mlx ? "Apple Silicon" : "NVIDIA")).length > 1 && ( diff --git a/docs-site/tests/cookbook.spec.ts b/docs-site/tests/cookbook.spec.ts index 8afd48d1b..102874123 100644 --- a/docs-site/tests/cookbook.spec.ts +++ b/docs-site/tests/cookbook.spec.ts @@ -1,6 +1,12 @@ import { expect, test, type Page } from "@playwright/test"; const route = "/FastVideo/cookbook/minimax-h3/fasth3-8step"; + +// The cookbook has no behavioral dependency on third-party scripts. Keep load +// deterministic when GitHub/Trendshift are slow or unreachable. +test.beforeEach(async ({ page }) => { + await page.route(/^https?:\/\/(?!127\.0\.0\.1|localhost)/, (request) => request.abort()); +}); async function settings(page: Page, section: string) { await expect(page.locator(".recipe-matrix")).toBeVisible(); if (section === "Serving" || section === "Hardware") { @@ -75,12 +81,12 @@ test("copy buttons use their own generated output", async ({ page, context }) => server, ")", ].join("\n"); - await page.getByRole("button", { name: "Copy", exact: true }).click(); + await page.getByRole("button", { name: "Copy config + command", exact: true }).click(); await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(launch); await page.locator(".recipe-request-code > summary").click(); const client = await page.getByLabel("Client command", { exact: true }).innerText(); expect(client).toContain(":9001/"); - await page.getByRole("button", { name: "Copy client command", exact: true }).click(); + await page.getByRole("button", { name: "Copy request command", exact: true }).click(); await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(client); expect(new Set([yaml, launch, client]).size).toBe(3); }); @@ -207,7 +213,9 @@ test("hardware preferences stay distinct from schema-backed GPU count and topolo const yaml = page.getByLabel("Generated YAML", { exact: true }); await expect(yaml).toContainText('"num_gpus": 1'); await expect(yaml).not.toContainText('"hardware"'); - await expect(page.getByText("Unverified recipe", { exact: true })).toBeVisible(); + await expect(page.getByText("Valid configuration", { exact: true })).toBeVisible(); + await expect(page.getByText("Fit unknown", { exact: true })).toBeVisible(); + await expect(page.getByText("No recorded run", { exact: true })).toBeVisible(); await page.getByLabel("GPU count", { exact: true }).fill("0"); await expect(yaml).toHaveCount(0); await expect(page.getByLabel("GPU count", { exact: true })).toHaveAttribute("aria-invalid", "true"); @@ -261,13 +269,13 @@ test("image and audio models have logos and dedicated source recipe pages", asyn ).toHaveAttribute("aria-pressed", "true"); await expect(page.getByRole("button", { name: "Serving", exact: true })).toHaveCount(0); await page.getByLabel("Seed", { exact: true }).fill("0"); - await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Copy example + command", exact: true })).toBeEnabled(); await page.goto("/FastVideo/cookbook"); await page.getByRole("button", { name: "Audio", exact: true }).click(); await expect(page.locator(".recipe-card")).toHaveCount(3); await page.locator(".recipe-card").first().click(); await page.getByLabel("Audio duration", { exact: true }).fill("1.5"); - await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Copy example + command", exact: true })).toBeEnabled(); }); test("provider labels match FastVideo logos and CompactH3 is replaced by FastH3 Trim", async ({ page }) => { @@ -282,7 +290,6 @@ test("provider labels match FastVideo logos and CompactH3 is replaced by FastH3 "href", /FastVideo-FastH3-Trim-8-Step-NVFP4/, ); - await expect(page.getByRole("button", { name: "Copy YAML", exact: true })).toBeEnabled(); }); test("FastH3 Trim edits drive inference, serving, and complete copyable launch scripts", async ({ page }) => { @@ -296,11 +303,12 @@ test("FastH3 Trim edits drive inference, serving, and complete copyable launch s }); }); await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-trim"); + await page.getByRole("radio", { name: "DGX Spark GB10", exact: true }).check(); await expect(page.getByRole("radio", { name: "DGX Spark GB10", exact: true })).toBeChecked(); await expect(page.getByRole("radio", { name: "H100 Data center", exact: true })).toHaveCount(0); await page.getByLabel("Prompt", { exact: true }).fill("A potter finishes a blue bowl."); await page.getByLabel("Seed", { exact: true }).fill("0"); - await page.getByRole("button", { name: "Copy", exact: true }).click(); + await page.getByRole("button", { name: "Copy config + command", exact: true }).click(); const script = await page.evaluate(() => (window as Window & { copiedCommand?: string }).copiedCommand); expect(script).toContain("cat > config.yaml <<'FASTVIDEO_CONFIG'"); expect(script).toContain("FastVideo-FastH3-Trim-8-Step-NVFP4"); @@ -332,19 +340,19 @@ test("Wan I2V requires an image for CLI and SDK examples without changing server await page.goto("/FastVideo/cookbook/wan/wan21-i2v"); await chip(page, "Run mode", "CLI"); await expect(page.getByRole("alert")).toContainText("Enter a source image path"); - await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Copy config + command", exact: true })).toBeDisabled(); await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeDisabled(); await page.getByLabel("Source image path", { exact: true }).fill("/data/first-frame.png"); await output(page, "config.yaml"); await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText("image_path: /data/first-frame.png"); - await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Copy config + command", exact: true })).toBeEnabled(); await chip(page, "Run mode", "Python SDK"); await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText("image_path: /data/first-frame.png"); await page.getByLabel("Source image path", { exact: true }).fill(""); await expect(page.getByRole("button", { name: "Copy YAML", exact: true })).toBeDisabled(); await expect(page.getByLabel("Generated YAML", { exact: true })).toHaveCount(0); await chip(page, "Run mode", "Serving"); - await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Copy config + command", exact: true })).toBeEnabled(); await expect(page.getByLabel("Generated YAML", { exact: true })).not.toContainText("image_path"); }); @@ -368,12 +376,12 @@ test("source-backed serving pages expose their actual server settings", async ({ await chip(page, "Run mode", "Serving"); await page.locator(".matrix-advanced > summary").click(); await page.getByLabel("Port", { exact: true }).fill("9005"); - await page.getByRole("button", { name: "cURL", exact: true }).click(); + await page.getByRole("button", { name: "Show request", exact: true }).click(); await expect(page.getByLabel("Client command", { exact: true })).toContainText(":9005/v1/videos/sync"); await page.getByRole("tab", { name: "config.yaml", exact: true }).click(); await expect(page.getByLabel("Source example", { exact: true })).toContainText("port: 9005"); await expect(page.getByLabel("Source example", { exact: true })).toContainText("Wan2.2-T2V-A14B"); - await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Copy config + command", exact: true })).toBeEnabled(); }); test("source model hardware preferences survive hydration and reload", async ({ page }) => { @@ -386,7 +394,7 @@ test("source model hardware preferences survive hydration and reload", async ({ expect(errors).toEqual([]); }); -test("custom devices persist and component previews cannot silently generate incompatible YAML", async ({ page }) => { +test("custom devices persist and no preview control can block generated YAML", async ({ page }) => { await page.goto(route); await page.getByRole("button", { name: "+ Find or add a GPU", exact: true }).click(); await page.getByLabel("Device name", { exact: true }).fill("RTX 4090"); @@ -394,10 +402,54 @@ test("custom devices persist and component previews cannot silently generate inc await expect(page.getByRole("radio", { name: "RTX 4090 Custom device", exact: true })).toBeChecked(); await page.reload(); await expect(page.getByRole("radio", { name: "RTX 4090 Custom device", exact: true })).toBeChecked(); - await page.locator(".component-plan > summary").click(); - await chip(page, "Text encoder", "NVFP4 encoder"); - await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeDisabled(); - await expect(page.getByRole("alert")).toContainText("compatible serving recipe"); - await page.getByRole("button", { name: "Restore recipe components", exact: true }).click(); + await expect(page.locator(".component-plan")).toHaveCount(0); await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled(); + await page.getByRole("button", { name: "Clear hardware target", exact: true }).click(); + await expect(page.getByRole("radio", { name: "RTX 4090 Custom device", exact: true })).toHaveCount(0); + await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible(); +}); + +test("hardware is never preselected and a target does not change generated YAML", async ({ page }) => { + await page.goto(route); + await expect(page.locator(".hardware-picker")).toBeVisible({ timeout: 30000 }); + await expect(page.locator('input[type="radio"]:checked')).toHaveCount(0); + await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible(); + await output(page, "config.yaml"); + const yaml = page.getByLabel("Generated YAML", { exact: true }); + const baseline = await yaml.innerText(); + await page.getByRole("radio", { name: /RTX 5090/ }).check(); + await expect(page.getByRole("radio", { name: /RTX 5090/ })).toBeChecked(); + await expect(page.getByText(/RTX 5090 is saved as your target/)).toBeVisible(); + await expect(yaml).toHaveText(baseline); + await page.getByRole("button", { name: "Clear hardware target", exact: true }).click(); + await expect(page.locator('input[type="radio"]:checked')).toHaveCount(0); + await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible(); +}); + +test("a saved target missing from the list stays visible and clearable", async ({ page }) => { + await page.goto(`${route}?hardware=retired-gpu`); + await expect(page.getByText("Saved hardware target is not in this list.", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Clear hardware target", exact: true }).click(); + await expect(page).not.toHaveURL(/hardware=/); +}); + +test("recipe facts separate validity, fit, and run evidence with inspectable assumptions", async ({ page }) => { + await page.goto(route); + const facts = page.getByLabel("Recipe facts"); + await expect(facts.getByText("Valid configuration", { exact: true })).toBeVisible(); + await expect(facts.getByText("Fit unknown", { exact: true })).toBeVisible(); + await expect(facts.getByText("No recorded run", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Details", exact: true }).click(); + await expect(page.getByText(/This is not a fit claim/)).toBeVisible(); + await expect(page.getByText(/do not establish this configuration/)).toBeVisible(); +}); + +test("WebSocket deployments expose only the serving interface", async ({ page }) => { + await page.goto("/FastVideo/cookbook/ltx/ltx2-distilled"); + const modes = page.getByRole("group", { name: "Run mode" }); + await expect(modes.getByRole("button")).toHaveCount(1); + await expect(modes.getByRole("button", { name: "Serving", exact: true })).toHaveAttribute("aria-pressed", "true"); + await expect(modes.getByRole("button", { name: "Python SDK", exact: true })).toHaveCount(0); + await expect(modes.getByRole("button", { name: "CLI", exact: true })).toHaveCount(0); + await expect(page.getByText(/Only serving is recorded for it/)).toBeVisible(); }); diff --git a/docs-site/tests/evidence/cookbook-ui-trust/README.md b/docs-site/tests/evidence/cookbook-ui-trust/README.md new file mode 100644 index 000000000..68ac827fc --- /dev/null +++ b/docs-site/tests/evidence/cookbook-ui-trust/README.md @@ -0,0 +1,15 @@ +# Cookbook UI evidence + +Captured 2026-10-19 against the local production build (`blume build`) served by +`blume preview --host 127.0.0.1 --port 4327`, Playwright Chromium headless shell +1248, viewport 1440×1000. Third-party requests were aborted; no product behavior +depends on them. + +| Before | After | What changed | +| ------------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `before/directory-desktop.png` | `after/directory-desktop.png` | Command labels and source/model entry points; no preselected hardware. | +| `before/fasth3-desktop.png` | `after/fasth3-desktop.png` | Hardware is explicitly unset, the component preview is gone, and fit/evidence facts sit at the hardware decision point instead of above the command. | + +Narrow, dark, and MLX states were checked locally but are not committed. The +interaction tests cover those states, copied YAML/commands, reload persistence, +and the unselected/default behavior. diff --git a/docs-site/tests/evidence/cookbook-ui-trust/after/directory-desktop.png b/docs-site/tests/evidence/cookbook-ui-trust/after/directory-desktop.png new file mode 100644 index 000000000..c0b585c93 Binary files /dev/null and b/docs-site/tests/evidence/cookbook-ui-trust/after/directory-desktop.png differ diff --git a/docs-site/tests/evidence/cookbook-ui-trust/after/fasth3-desktop.png b/docs-site/tests/evidence/cookbook-ui-trust/after/fasth3-desktop.png new file mode 100644 index 000000000..0c10c0beb Binary files /dev/null and b/docs-site/tests/evidence/cookbook-ui-trust/after/fasth3-desktop.png differ diff --git a/docs-site/tests/evidence/cookbook-ui-trust/before/directory-desktop.png b/docs-site/tests/evidence/cookbook-ui-trust/before/directory-desktop.png new file mode 100644 index 000000000..226813f09 Binary files /dev/null and b/docs-site/tests/evidence/cookbook-ui-trust/before/directory-desktop.png differ diff --git a/docs-site/tests/evidence/cookbook-ui-trust/before/fasth3-desktop.png b/docs-site/tests/evidence/cookbook-ui-trust/before/fasth3-desktop.png new file mode 100644 index 000000000..090546af5 Binary files /dev/null and b/docs-site/tests/evidence/cookbook-ui-trust/before/fasth3-desktop.png differ diff --git a/docs-site/theme.css b/docs-site/theme.css index 6cf0794c8..57ea724e4 100644 --- a/docs-site/theme.css +++ b/docs-site/theme.css @@ -721,19 +721,58 @@ border-block-end: 1px solid var(--recipe-line); font-size: 0.6875rem; } -.recipe-unverified { - display: inline-flex; +.recipe-facts { + display: flex; + margin-block-start: 0.75rem; + flex-wrap: wrap; align-items: center; - gap: 0.5rem; + gap: 0.375rem 0.75rem; + min-inline-size: 0; +} +.recipe-fact-list { + display: flex; + flex-wrap: wrap; + gap: 0.375rem 0.75rem; color: var(--blume-muted-foreground); } -.recipe-unverified::before { +.recipe-fact-list span { + display: inline-flex; + align-items: center; + gap: 0.375rem; +} +.recipe-fact-list span::before { content: ""; inline-size: 0.375rem; block-size: 0.375rem; border-radius: 50%; background: currentColor; } +.recipe-fact-list span[data-state="valid"], +.recipe-fact-list span[data-state="fits"] { + color: var(--blume-foreground); +} +.recipe-facts-toggle { + font-size: 0.625rem; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} +.recipe-facts-detail { + flex-basis: 100%; + max-inline-size: 46rem; + color: var(--blume-muted-foreground); + font-size: 0.6875rem; + line-height: 1.65; +} +.recipe-facts-detail p + p { + margin-block-start: 0.375rem; +} +.recipe-mode-note { + margin: -0.25rem 0 1rem; + color: var(--blume-muted-foreground); + font-size: 0.6875rem; + line-height: 1.6; +} .recipe-tabs { display: flex; gap: 1rem; @@ -1166,13 +1205,29 @@ background: var(--blume-background); border-color: var(--recipe-control); } -.hardware-add { +.hardware-picker-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0.75rem; margin-block-start: 0.5rem; +} +.hardware-add, +.hardware-clear { min-block-size: 2rem; font-size: 0.6875rem; color: var(--blume-muted-foreground); cursor: pointer; } +.hardware-clear { + text-decoration: underline; + text-underline-offset: 3px; +} +.hardware-current { + margin-block-start: 0.5rem; + color: var(--blume-muted-foreground); + font-size: 0.6875rem; +} .hardware-search { display: grid; gap: 0.5rem; @@ -1199,39 +1254,6 @@ .hardware-matrix strong { overflow-wrap: anywhere; } -.component-plan > summary { - padding: 0; - cursor: pointer; - font-size: 0.75rem; -} -.component-plan > summary span { - margin-inline-start: 0.5rem; - color: var(--blume-muted-foreground); - font-size: 0.625rem; -} -.component-plan-fields { - display: grid; - gap: 1rem; - margin-block: 1rem; -} -.component-plan-label { - display: block; - margin-block-end: 0.5rem; - font-size: 0.6875rem; -} -.component-plan > p { - color: var(--blume-muted-foreground); - font-size: 0.6875rem; - line-height: 1.6; -} -.component-plan-reset { - margin-block-start: 0.5rem; - font-size: 0.6875rem; - min-block-size: 2rem; - text-decoration: underline; - text-underline-offset: 3px; - cursor: pointer; -} .release-intro { font-size: 0.875rem; color: var(--blume-muted-foreground);