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 with positive typed authority from a verbatim user request or a named inspected behavioral reference with observed locked interaction; a still image, screenshot, or desired output frame is composition evidence only.', ); 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( "Direct model interaction priority is fixed at pointer-down: CanvasShell claims Space + primary drag for pan before scene handlers; otherwise higher-priority product items may claim first, a visible-model hit orbits, and a miss does not pan. Modified primary presses and non-primary presses are not claimed by model orbit.", ); expect(contract.aiUsageRules).toContain( "Double-click an axis endpoint or the circular gizmo background to restore the active control's exact defaultValue through the shared runtime target. Cancel pending snaps; the preceding single-click snap and double-click reset form one undoable gesture. Canvas pan/zoom and other controls remain unchanged.", ); expect(contract.aiUsageRules).toContain( "Shift-drag on the gizmo locks the dominant pointer axis: horizontal yaw or vertical pitch. Keep that axis until Shift is released, and support mid-drag modifier changes without jumps. Direct model Shift-drag is unclaimed by model orbit and canvas pan still requires Space; do not replace these built-in gestures in product code.", ); 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 Space + primary drag outside the model 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 sits left of the Blanc/Dots workspace selector in the row below, and Timeline and optional Lock rotation share the final Setup row.', ); expect(color.aiUsageRules).toContain( "CanvasShell reads the evaluated runtime background color and owns both live modes: finite renders below runtime model/image media and transparent product content; Infinity fills the complete viewport without a duplicate finite layer. Product renderers stay transparent in live preview. export.includeBackground controls the runtime layer 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( "A standalone plain Color fills the available row. In a multi-color bank without opacity, keep an odd trailing plain Color at half width; an unpaired plain Color in an opacity bank fills its stacked 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( "A standalone plain Color fills the available row. In a multi-color bank without opacity, keep an odd trailing plain Color at half width; an unpaired plain Color in an opacity bank fills its stacked row.", ); }); });