import { describe, expect, it } from "vitest"; import { getToolcraftComponentContract } from "./component-contracts"; describe("Toolcraft template component contracts: visual controls", () => { it("keeps Color UI payloads behind the canonical string value model", () => { const contract = getToolcraftComponentContract("color"); expect(contract.aiUsageRules).toContain( "Color product state is a canonical hex string even though the UI callback payload is { hex }.", ); }); it("marks standalone controls as component-labeled standalone controls", () => { for (const id of [ "palette", "vector", "color", "colorOpacity", "gradient", "fontPicker", "curves", "anchorGrid", "channelMixer", "imagePicker", ] as const) { const contract = getToolcraftComponentContract(id); expect(contract.kind).toBe("control"); expect(contract.defaultSectionLayout).toBe("standalone"); expect(contract.labelPolicy).toBe("component-owned"); } }); it("documents vector density and section grouping rules", () => { const contract = getToolcraftComponentContract("vector"); expect(contract.aiUsageRules).toContain( "If the controls panel contains exactly one vector control, the runtime renders the vector pad as a square.", ); expect(contract.aiUsageRules).toContain( "Multiple vector controls should live in separate semantic sections unless they intentionally belong to the same entity with other related controls.", ); expect(contract.decisionCatalog?.ownsValueModel).toEqual( expect.arrayContaining([ "user-authored stable position", "user-authored stable offset", "user-authored stable direction", ]), ); expect(contract.decisionCatalog?.useWhen).toContain( "Use Vector for paired X/Y values only when the user is meant to manually author a stable two-axis product parameter such as position, offset, direction, focus, anchor, light direction, or color-balance movement.", ); expect(contract.decisionCatalog?.doNotReplaceWith).toContain( "Do not replace animation, keyboard input, pointer input, physics, timeline phase, velocity, or simulated pose state with Vector just because the internal value has x/y coordinates.", ); expect(contract.decisionCatalog?.acceptableAlternatives).toContain( "Use timeline, keyboard/pointer handlers, motion sliders, path/step controls, or renderer simulation state when movement is generated by animation or user input rather than authored as a stable panel value.", ); expect(contract.aiUsageRules).toContain( "Use Vector only for user-authored stable two-axis product parameters. Do not expose Vector for current animation state, keyboard movement, pointer movement, physics state, timeline phase, velocity, target pose, current pose, or simulated position/direction.", ); expect(contract.aiUsageRules).toContain( "Before adding a Vector control to an animated or interactive product, classify movement ownership as direct-authored, timeline-driven, keyboard/pointer-driven, or simulation-owned. Only direct-authored movement may become a visible Vector control; the other ownership modes stay in renderer/runtime interaction state and use controls such as Speed, Step, Spread, Path, Duration, or Timeline when the user needs tuning.", ); expect(contract.aiUsageRules).toContain( 'Use variant: "whiteBalance" for temperature/tint pads: X maps cool blue to warm amber, Y maps green to magenta.', ); expect(contract.aiUsageRules).toContain( 'Use variant: "colorBalance" for paired color-balance axes such as cyan/red and blue/yellow correction.', ); expect(contract.aiUsageRules).toContain( "Use the default vector variant for spatial values such as position, offset, direction, focus, anchor, and light direction.", ); expect(contract.aiUsageRules).toContain( 'Default spatial vector pads use coordinateMode: "screen": dragging left/up makes vector.x and vector.y smaller so canvas objects move left/up without renderer-side Y inversion.', ); expect(contract.aiUsageRules).toContain( "Vector pad value labels render compact rounded coordinates. Do not expose raw floating-point strings such as -0.07070312499999998 in the controls panel.", ); expect(contract.aiUsageRules).toContain( "Vector product state always stores finite numeric x and y values; decimal strings exist only at the visual input boundary and canonicalize before state or keyframes.", ); expect(contract.aiUsageRules).toContain( "Double-clicking a vector pad resets both axes to the control default through the normal runtime value update, matching section header reset semantics; if no default is defined, the fallback is 0,0. Do not add a separate custom reset UI for this basic pad reset behavior.", ); expect(contract.aiUsageRules).toContain( "Holding Shift while dragging a vector pad locks movement to the dominant axis and must not select text or page content; do not build a custom pad just to support axis-constrained movement.", ); expect(contract.aiUsageRules).toContain( 'Use coordinateMode: "cartesian" only when the product intentionally exposes mathematical Y-up coordinates instead of canvas/screen movement.', ); expect(contract.aiUsageRules).toContain( "Vector is a compound control; acceptance must prove vector.x and vector.y both affect the product output and that the vector represents a user-authored stable two-axis parameter rather than current animation, input, or simulation state.", ); expect(contract.aiUsageRules).toContain( "Do not use Vector for a camera orbit, object orbit, or a visually flat object that users can rotate through three-dimensional space; use OrientationGizmo and direct model drag instead.", ); }); it("assigns three-dimensional rotation to the runtime orientation gizmo", () => { const contract = getToolcraftComponentContract("orientationGizmo"); expect(contract.labelPolicy).toBe("hidden"); expect(contract.decisionCatalog?.strictness).toBe("exact-owner"); expect(contract.decisionCatalog?.useWhen).toContain( "Use OrientationGizmo whenever the user can rotate a visible canvas model through three-dimensional space, including a volumetric 3D object or a visually flat object with a 3D orbit.", ); expect(contract.decisionCatalog?.doNotReplaceWith).toContain( "Do not replace OrientationGizmo with Vector, paired sliders, segmented axis buttons, or a product-authored custom gizmo.", ); expect(contract.decisionCatalog?.acceptableAlternatives).toContain( 'Use viewInteraction mode "fixed-camera" only when an explicit user request or inspected reference requires fixed framing; choosing a fixed camera merely because rotation was not requested is not an accepted alternative.', ); expect(contract.aiUsageRules).toContain( 'Declare typed product readiness viewInteraction before controls or renderer code. A visible editable spatial scene defaults to mode "orbit"; do not make the scene non-rotatable first and then use that implementation choice to omit OrientationGizmo.', ); expect(contract.aiUsageRules).toContain( "Use useToolcraftModelOrbitInteraction in the product renderer. Supply a hitTest callback based on actual visible geometry; runtime must not guess product triangles, alpha, paths, or occlusion.", ); expect(contract.aiUsageRules).toContain( "Interaction priority is fixed at pointer-down: higher-priority product items may claim first, a visible-model hit orbits, and a miss remains untouched so CanvasShell pans the canvas. Modified primary presses and non-primary presses are not claimed.", ); expect(contract.aiUsageRules).toContain( "Multiple orientationGizmo declarations are allowed only for statically provable mutually exclusive modes; runtime rejects any state that makes more than one canvas orientation handle visible.", ); expect(contract.aiUsageRules).toContain( "Gizmo drag, snap animation, and direct model orbit share target-scoped runtime ownership. A newer interaction, reset, undo/redo, or another write to the target cancels stale work before it can overwrite current state.", ); expect(contract.decisionCatalog?.requiredAcceptance).toContain( "Use the protected model-drag and canvas-miss-pan recipes to prove a visible-model hit changes the shared pose/output while a miss pans without changing either.", ); }); it("documents Palette as a constrained design-token color control", () => { const contract = getToolcraftComponentContract("palette"); expect(contract.decisionCatalog?.ownsValueModel).toContain( "style-guide color token", ); expect(contract.decisionCatalog?.useWhen).toContain( "Use Palette for token-based color choices such as brand palettes, Tailwind-like shade scales, semantic palette families, or style-guide colors.", ); expect(contract.decisionCatalog?.doNotReplaceWith).toContain( "Do not use Palette for gradients, free hex colors, text color inside FontPicker, or a color value that owns opacity.", ); expect(contract.aiUsageRules).toContain( "Use Palette only when the product value is a constrained design-token palette choice with both family and shade.", ); expect(contract.aiUsageRules).toContain( "Good Palette examples: brand palette family and shade, Tailwind-like token color, style-guide color scale, semantic palette family, or theme accent token.", ); expect(contract.aiUsageRules).toContain( "Do not use Palette for arbitrary free color picking; use Color instead.", ); expect(contract.aiUsageRules).toContain( "Do not use Palette when opacity belongs to the same color entity; use ColorOpacity instead.", ); expect(contract.decisionCatalog?.requiredAcceptance).toContain( "Prove Palette family and shade selections update runtime state immediately, before delayed persistence/commit settles.", ); expect(contract.aiUsageRules).toContain( "Palette is a live control like Color and Slider: family and shade changes must update runtime state immediately so the next canvas interaction uses the selected token without waiting for delayed commit or persistence timers.", ); expect(contract.aiUsageRules).toContain( "Do not use Palette for gradients or color transitions; use Gradient instead.", ); expect(contract.aiUsageRules).toContain( "Do not split typography color out to Palette when the text styling belongs to FontPicker.", ); }); it("documents compound control part coverage requirements", () => { const color = getToolcraftComponentContract("color"); const colorOpacity = getToolcraftComponentContract("colorOpacity"); const gradient = getToolcraftComponentContract("gradient"); const fontPicker = getToolcraftComponentContract("fontPicker"); const rangeInput = getToolcraftComponentContract("rangeInput"); const palette = getToolcraftComponentContract("palette"); const curves = getToolcraftComponentContract("curves"); const anchorGrid = getToolcraftComponentContract("anchorGrid"); const channelMixer = getToolcraftComponentContract("channelMixer"); const appEntityAcceptance = getToolcraftComponentContract( "appEntityAcceptance", ); expect(color.aiUsageRules).toContain( "Product-output apps always expose renderer-owned output background color as a schema color target such as appearance.background or scene.background.", ); expect(color.aiUsageRules).toContain( 'Declare renderer-owned output background color with export.includeBackground in one authored Background source section. Runtime normalizes them into Setup: a Switch labeled "Background" sits left of Infinity canvas in an equal-width row, Background color renders below, and Timeline remains the final Setup control.', ); expect(color.aiUsageRules).toContain( "Preview, PNG export, video export, and the infinite runtime viewport must read the runtime background color value instead of hardcoding that background in CSS, Canvas fillStyle, or WebGL clearColor. Product renderers suppress their bounded background in Infinity mode while CanvasShell fills the complete viewport; export.includeBackground controls live preview product background and PNG alpha, and disabling it restores finite mode instead of leaving a transparent infinite workspace.", ); expect(color.aiUsageRules).toContain( "When one short numeric/text field and one Color field configure the same entity, keep them in one two-column inline layout group.", ); expect(color.aiUsageRules).toContain( "Mixed inline rows usually require visible labels on both controls. Toggle-plus-parameter rows are the section-owned exception: keep the Switch/Checkbox label visible and set the non-toggle parameter label to false; if the parameter label is needed, stack the controls instead. All 50/50 inline rows use the same horizontal column gap as paired Select controls. Palette variation color banks are the other exception when the group/section label already names the color bank.", ); expect(color.aiUsageRules).toContain( "Plain Color popovers must not show opacity controls. If opacity is editable, use ColorOpacity instead.", ); expect(color.aiUsageRules).toContain( "Decide color label visibility from the user's point of view. Omit labels for color banks that only add palette variety, such as Accent Shades, Bead Colors, or palette.accent1..5.", ); expect(color.aiUsageRules).toContain( "Make color label visibility a group-level decision: do not mix labeled and unlabeled items inside one semantic color bank.", ); expect(color.aiUsageRules).toContain( "In a mixed section with multiple plain Color controls, declare semanticGroup on every plain Color; runtime pairs only adjacent colors in the same semantic group. A color-only section is one implicit bank.", ); expect(color.aiUsageRules).toContain( "Keep visible labels when colors edit distinct user-facing entities or roles, such as Fill, Stroke, Background, Connector, Object, or Highlight.", ); expect(color.aiUsageRules).toContain( "A color bank can share a section with distribution controls such as Spread, Mix, or Randomness and still keep each color item unlabeled when the section title names the palette context.", ); expect(color.aiUsageRules).toContain( "If a multi-color bank has an odd trailing plain Color, keep that last Color at half width; only ColorOpacity or intentionally full-width compound controls occupy a full row.", ); expect(gradient.aiUsageRules).toContain( "Gradient is a compound control; acceptance must prove gradient.gradientType, gradient.angle, gradient.stops.position, gradient.stops.color, and gradient.stops.opacity all affect the product output when visible.", ); expect(gradient.aiUsageRules).toContain( "Keep Gradient type/angle, draggable stop track, and Stops list inside the built-in Gradient control. The full Gradient control is visually separated with content-width dividers only when it shares a section with sibling controls; do not put dividers only around the Stops list and do not rebuild it as separate schema controls.", ); expect(gradient.aiUsageRules).toContain( "If the renderer intentionally supports only a subset of gradient behavior, do not use the full Gradient control; use simpler controls that match the renderer behavior.", ); expect(fontPicker.aiUsageRules).toContain( "FontPicker owns the font preview select, virtualized font popup, category filters, search, preview loading, font-weight select, font-size input, text-case select, text color/opacity control, letter-spacing slider, and line-height slider.", ); expect(fontPicker.aiUsageRules).toContain( 'Do not recreate FontPicker with a plain Select plus separate sliders; use type: "fontPicker" so the popup mechanics and footer controls stay intact.', ); expect(fontPicker.aiUsageRules).toContain( "FontPicker standard/default text color is #FFFFFF with opacity 100; omit color/opacity or use those values unless the prompt or reference explicitly requires a different initial text color.", ); expect(fontPicker.aiUsageRules).toContain( "Any product text controlled by FontPicker must render fontId, fontWeight, fontSize, letterSpacing, lineHeight, textCase, color, and opacity in preview and export; do not leave typography values as panel-only runtime state.", ); expect(fontPicker.aiUsageRules).toContain( "FontPicker is an atomic compound typography control. Do not split any owned typography part into a neighboring schema control for the same product text entity.", ); expect(fontPicker.aiUsageRules).toContain( "FontPicker is a compound control; acceptance must prove fontPicker.fontId, fontPicker.fontWeight, fontPicker.fontSize, fontPicker.letterSpacing, fontPicker.lineHeight, fontPicker.textCase, fontPicker.color, and fontPicker.opacity all affect the product output.", ); expect(fontPicker.aiUsageRules).toContain( "FontPicker acceptance must inspect the actual product text output after changing font, weight, size, letter spacing, line height, text case, color, and opacity; runtime state, select labels, or popup preview text alone are not enough.", ); expect(colorOpacity.aiUsageRules).toContain( 'Do not split ColorOpacity into a separate Color plus Slider/Input for opacity; use type: "colorOpacity" so the color popover and percent input stay visually connected.', ); expect(colorOpacity.aiUsageRules).toContain( "ColorOpacity is the only color control variant that may expose opacity in the color picker popover; plain Color popovers hide opacity controls.", ); expect(colorOpacity.aiUsageRules).toContain( "Do not place ColorOpacity in inline two-column layout groups. If either color control has opacity, keep the controls stacked.", ); expect(colorOpacity.aiUsageRules).toContain( "Only plain Color controls without opacity may render in two-column color rows.", ); expect(colorOpacity.aiUsageRules).toContain( "Acceptance must prove colorOpacity.hex and colorOpacity.opacity both affect the product output; testing only the swatch or only runtime state is not enough.", ); expect(rangeInput.aiUsageRules).toContain( "RangeInput is a compound control; acceptance must prove rangeInput.start and rangeInput.end both affect the product output.", ); expect(palette.aiUsageRules).toContain( "Palette is a compound control; acceptance must prove palette.family and palette.shade both affect the product output.", ); expect(curves.aiUsageRules).toContain( 'Use Curves for editable remapping curves. RGB/R/G/B tabs are only for color-correction or channel-specific curves; use variant: "single" for one standalone curve without channel tabs.', ); expect(curves.aiUsageRules).toContain( 'Every curves control declares curveIntent: "single-value-map" or "color-channels". Labels and target names are not curve-composition evidence.', ); expect(curves.aiUsageRules).toContain( 'curveIntent: "single-value-map" requires variant: "single"; curveIntent: "color-channels" uses the channel composition.', ); expect(curves.aiUsageRules).toContain( 'Use variant: "single" for a single acceleration, bend, easing, opacity, response, depth, mask, threshold, tone-response, or mapping curve. Do not create a custom curve UI just to remove RGB tabs.', ); expect(curves.aiUsageRules).toContain( "RGB Curves is a color-correction-specific case; do not force RGB/R/G/B tabs onto products that need only one response, bend, depth, or easing curve.", ); expect(curves.aiUsageRules).toContain( 'Use interpolation: "smooth" for photo/editor-like visual tone, color, and RGB curves where the curve should feel like a creative editor spline.', ); expect(curves.aiUsageRules).toContain( 'Use interpolation: "monotone" for depth, response, mask, opacity, threshold, and data-mapping curves where order must be preserved and overshoot is unsafe. Single curves default to monotone unless smooth is explicitly requested.', ); expect(curves.aiUsageRules).toContain( "Single Curves is one labeled control without internal dividers; RGB Curves is the compound variant with channel tabs and section dividers when mixed with sibling controls.", ); expect(curves.aiUsageRules).toContain( "RGB curves acceptance must prove curves.activeChannel and curves.points both affect the product output. Single curves acceptance proves curves.points.", ); expect(curves.aiUsageRules).toContain( "Curves acceptance should include an off-center control point near an edge so smooth-vs-monotone interpolation mistakes are visible in product output.", ); expect(anchorGrid.aiUsageRules).toContain( "AnchorGrid is a position selector; acceptance must prove anchorGrid.position changes product placement, not only selected button state.", ); expect(anchorGrid.decisionCatalog?.acceptableAlternatives).toContain( "Use Vector only for stable direct-authored continuous position or direction parameters.", ); expect(channelMixer.aiUsageRules).toContain( "ChannelMixer is RGB-specific: it renders R/G/B tabs and Red, Green, Blue sliders for an RGB channel matrix.", ); expect(channelMixer.aiUsageRules).toContain( "Use ChannelMixer only for RGB channel mixing, channel swapping, or color-correction matrix behavior; do not use it for arbitrary channel lists.", ); expect(channelMixer.decisionCatalog?.useWhen.join(" ")).toMatch( /RGB channel mixing/i, ); expect(channelMixer.decisionCatalog?.doNotReplaceWith?.join(" ")).toMatch( /arbitrary non-RGB channels/i, ); expect(channelMixer.aiUsageRules).toContain( "ChannelMixer is a compound control; acceptance must prove channelMixer.activeChannel and channelMixer.values both affect the product output.", ); expect(appEntityAcceptance.aiUsageRules).toContain( "Compound controls must declare controlPartCoverage for every semantic value part required by their control type.", ); }); it("documents FontPicker help as product-specific scope only", () => { const contract = getToolcraftComponentContract("fontPicker"); expect(contract.aiUsageRules).toContain( "Do not put a help tooltip on FontPicker just to list its owned fields. If the section title and FontPicker labels already make the text target clear, omit description.", ); }); it("documents semantic color section titles", () => { const contract = getToolcraftComponentContract("color"); expect(contract.aiUsageRules).toContain( "Color controls can be standalone color sections or grouped fields inside a semantic control section.", ); expect(contract.aiUsageRules).toContain( "First identify the semantic entity the color belongs to, such as Square 1, Square 2, Background, Object, Connector, Glow, Tone Mapping, Brand, or Export.", ); expect(contract.aiUsageRules).toContain( "Keep a color inside a section when it configures the same entity as nearby controls. Example: Square 1 (Right) contains Connections, Hover radius, and Color in one section.", ); expect(contract.aiUsageRules).toContain( "When color belongs to the same object or effect as nearby controls, keep it inside that section and use a concise field label that is unambiguous in context, such as Color in a Square section or Symbol color in a mixed Style section.", ); expect(contract.aiUsageRules).toContain( "The standalone default applies only to color-only sections; mixed semantic sections keep color grouped with nearby controls.", ); expect(contract.aiUsageRules).toContain( "Never use generic Color or Colors as a generated section title. If no meaningful color role exists and the colors are just basic colors, use a neutral section title such as Appearance instead of omitting the title.", ); expect(contract.aiUsageRules).toContain( "Do not split a grouped object section into a separate generated Color section; if the color role is unclear, ask the user before implementation.", ); expect(contract.aiUsageRules).toContain( "Render multiple related color fields in one section with at most two colors per row.", ); expect(contract.decisionCatalog?.layoutConstraints).toContain( "In a mixed section with multiple plain Color controls, declare semanticGroup on every plain Color; runtime pairs only adjacent colors in the same semantic group. A color-only section is one implicit bank.", ); expect(contract.aiUsageRules).toContain( "In a mixed section with multiple plain Color controls, declare semanticGroup on every plain Color; runtime pairs only adjacent colors in the same semantic group. A color-only section is one implicit bank.", ); expect(contract.aiUsageRules).toContain( "If a multi-color bank has an odd trailing plain Color, keep that last Color at half width; only ColorOpacity or intentionally full-width compound controls occupy a full row.", ); }); });