@digital_400/next-craft
v2.0.5
Published
Next craft product
Readme
next-craft
A React component library for Next Craft — a powerful product customization platform that enables users to create custom products using base products. With Next Craft, users can select from a variety of base products and personalize them with custom text, images, colors, and design elements through an intuitive canvas-based editor.
Overview
Next Craft provides a complete solution for building product customization experiences. The library includes:
- Base Product Selection: Browse and select from a catalog of base products with support for multiple variants (color, material, size). Includes optional search and category/type filtering.
- Visual Editor: Canvas-based editing interface powered by Fabric.js for adding and manipulating design elements.
- Customization Features:
- Add custom text with font family, size, weight, style, alignment, and decoration controls
- Upload and position images/assets on products
- Color customization with an integrated color picker
- Shape library: circle, rectangle, triangle, star, pentagon, hexagon
- Freehand drawing with configurable stroke width and color
- Layer management for complex designs
- Undo/redo functionality for design iterations
- Product Variants: Support for products with multiple options including color, material, size, and custom clip paths.
- Asset Management: Organize and manage common workspace assets and product-specific assets.
- Pricing Engine: Real-time cost breakdown for text, drawings, shapes, and uploaded assets — displayed in a draggable price panel overlay.
- AI Vector Art Generation: Wire in your own AI backend to generate vector art from a text prompt directly inside the editor.
- Sidebar Tab Visibility Control: Show or hide individual sidebar tabs per integration.
- Variant Selection Modal: Optionally delegate variant selection to a full modal overlay.
- AcceptableOptions: Constrain which colors, fonts, decorations, and opacity settings are available per product.
- JSON Import/Export: Save and restore complete canvas designs including product state.
- Glow Mode: Special illuminated display mode for applicable products.
- Fullscreen Mode: One-click fullscreen with keyboard shortcut support.
Table of Contents
- Installation
- Quick Start
- Component API
- Key Types
- Features
- Pricing Engine
- AI Vector Art Generation
- Sidebar Tab Visibility
- Shapes, Draw & Settings — Fine-Grained Configuration
- Logo Tab
- Header Button Visibility
- Wording (Typography)
- Custom Styles
- Auto-Select Single Options
- Variant Selection Modal
- Selection Modal Options
- Product-Type (Template) Mode
- Asset Section Visibility
- Layer Overlap Prevention
- Disable Text Resizing
- Clip Path Content-Type Restrictions
- JSON Import / Export
- AcceptableOptions
- Running the Application
- Build
- Releasing
Installation
# npm
npm install @digital_400/next-craft
# pnpm (recommended)
pnpm add @digital_400/next-craftImport the stylesheet alongside your component import — the library ships styles as a separate file:
import '@digital_400/next-craft/index.css'Peer dependencies — React 19 must be installed in your host application:
pnpm add react@19 react-dom@19Quick Start
import React, { useState } from 'react'
import { NextCraft } from '@digital_400/next-craft'
import '@digital_400/next-craft/index.css'
const PRODUCT_LIST = [
/* your base products */
]
export default function App() {
const [selectedProduct, setSelectedProduct] = useState(null)
return (
<NextCraft
// All domain data lives in the grouped `data` prop
data={{
baseProduct: {
list: PRODUCT_LIST,
selected: selectedProduct,
defaultVariant: {}
}
}}
// All host callbacks live in the grouped `actions` prop
actions={{
baseProduct: {
onSelect: () => {
/* open your product picker UI */
},
onSubmit: async id => {
const product = await fetchProduct(id)
setSelectedProduct(product)
},
onFetchDetails: async productId => {
return await fetchProduct(productId)
}
}
}}
// All configuration lives in the grouped `settings` prop
settings={{
workspace: {
name: 'My Store',
logo: 'https://example.com/logo.png'
}
}}
/>
)
}Component API
All props are on the <NextCraft /> component. Required props are marked with ✓.
Data Prop
All domain data flows through one grouped data prop (NextCraftData), organized by what it describes:
| Path | Type | Description |
|------|------|-------------|
| data.baseProduct.list | any[] | Full product catalog list; used internally for JSON import validation |
| data.baseProduct.selected | ISelectedProduct | The currently selected base product object |
| data.baseProduct.defaultVariant | IVariant | Initial variant object passed to the canvas on first render |
| data.baseProduct.searchResults | any | Search result data passed back from the host |
| data.assets.related | AssetCollection | Product-specific images shown in the Images tab ({ items, loading }) |
| data.assets.common | AssetCollection | Workspace-level images available across all products ({ items, loading }) |
| data.assets.generated | AssetCollection | Pre-generated AI image results for the Generate tab ({ items }) |
Actions Prop
All host callbacks flow through one grouped actions prop (NextCraftActions), organized by the feature they serve:
| Path | Type | Description |
|------|------|-------------|
| actions.baseProduct.onSelect | () => void | Called when the user clicks the "Select Product" button — use this to open your product picker UI |
| actions.baseProduct.onSubmit | (productId: string) => any | Called when the user confirms switching to a new base product |
| actions.baseProduct.onSearch | (value: string) => void | Called on search input change in the product catalog |
| actions.baseProduct.onFetchDetails | (productId: string) => Promise<any> | Async callback to fetch product details on demand |
| actions.sideBar.onMenuClick | (id: string) => void | Called when a sidebar menu icon is clicked; receives the selected base product id |
| actions.ai.onGenerateVectorArt | (prompt, { size }) => Promise<{ svgUrl?, colors?, svgText? }> | Custom handler for AI art generation requests (powers the Generate tab) |
Settings Prop
All configuration flows through one grouped settings prop (NextCraftSettings), organized by what it controls:
| Path | Type | Description |
|------|------|-------------|
| settings.displayOptions | DisplayOptions | Show/hide toggles for the product catalog and sidebar (see Features below) |
| settings.variantSelectionModal | boolean | Delegate variant selection to a full modal overlay |
| settings.typography | TypographySettings | Override built-in UI text labels |
| settings.textOptions | TextOptionsSettings | How the product-type (template) text options resolve — mode: 'merged' (default) or 'templateOnly' (see Product-Type Text Options) |
| settings.pricing | NextCraftPricing | Enable and configure the pricing engine |
| settings.workspace.name | string | Workspace or brand name shown in the header |
| settings.workspace.logo | string | Logo URL shown in the header |
| settings.customStyles | CustomStyles | Per-element CSS overrides (button, select, dropdown, etc.) plus a raw-CSS escape hatch — see Custom Styles |
| settings.layerOverlap | LayerOverlapSettings | Prevent configured layer categories from overlapping on the canvas — see Layer Overlap Prevention |
| settings.disableTextResize | boolean | Disable drag-resize on text objects (move/rotate/delete and the font-size dropdown are unaffected) — see Disable Text Resizing |
The AI generation callback itself lives at actions.ai.onGenerateVectorArt (see Actions Prop above).
Additional Props
| Prop | Type | Description |
|------|------|-------------|
| className | string | Additional CSS class merged onto the root .next-craft element |
| withContainer | boolean | Wrap output in the .next-craft root container (default: true) |
Key Types
NextCraftData
The grouped shape of the data prop — all domain data the host feeds into the editor:
interface NextCraftData {
baseProduct?: {
list?: any[] // product catalog
selected?: ISelectedProduct // currently selected base product
defaultVariant?: IVariant // initial variant seed for the canvas
searchResults?: any // host search results
}
assets?: {
related?: AssetCollection // product-specific images
common?: AssetCollection // workspace-wide shared images
generated?: AssetCollection // AI-generated image results
}
}
interface AssetCollection {
items?: IAssets[]
loading?: boolean
}NextCraftActions
The grouped shape of the actions prop — all host callbacks the editor invokes:
interface NextCraftActions {
baseProduct?: {
onSelect?: () => void // open the host's product picker
onSubmit?: (productId: string) => any // user confirmed a product switch
onSearch?: (value: string) => void // catalog search input changed
onFetchDetails?: (productId: string) => Promise<any> // fetch product details on demand
}
sideBar?: {
onMenuClick?: (id: string) => void // sidebar menu icon clicked
}
ai?: {
onGenerateVectorArt?: (
prompt: string,
options: { size: string }
) => Promise<{ svgUrl?: string; colors?: string[]; svgText?: string }>
}
}ISelectedProduct
The base product shape passed to data.baseProduct.selected:
interface ISelectedProduct {
id?: string
productName?: string
defaultImage?: string
variants?: IVariant[]
glowModeEnabled?: boolean
categoryTypes?: Array<{
id?: string
type?: string // e.g. 'Material', 'Colour'
preview?: {
color?: string
text?: string
thumbnailImage?: string
}
}>
acceptableOptions?: AcceptableOptions
}IVariant
A single product variant:
interface IVariant {
varientId?: string
Colour?: string
Material?: string
Size?: string
imageUrl?: string
price?: number | null
sku?: string // External catalog SKU for this variant
webId?: string // External web/order-system identifier for this variant
fontSizes?: number[] // Variant-specific font-size allow-list, e.g. [16, 24, 32] — see below
maxLines?: number // Variant-specific max text lines per text box — see below
isDefaultSelect?: boolean
isSelected?: boolean
clipPath?: {
shape?: string
x?: number
y?: number
width?: number
height?: number
}
}Font-size allow-list (IVariant.fontSizes) — a per-variant constraint (unlike the
per-product acceptableOptions below): when unset or empty, all sizes are offered as before. When
it has more than one value, the text controls' size dropdown offers only those sizes. When it has
exactly one value, that size is enforced as a hard lock — new text is created at that size, and
any text that drifts off it (switching to this variant, JSON import, or a direct programmatic
changeFontSize call) is automatically corrected back to it. Because this lives on the variant
rather than the product, switching Colour/Size/Material within the same product can change or
re-lock the available sizes live.
The global font-size catalog — the only valid values for the fontSizes allow-list — is:
8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56,
58, 60, 62, 64, 66, 68, 70, 72, 74, 76, 78, 80 (px, step 2)Any value outside this set is treated as invalid and falls back to the full list (same safety net as an empty/absent allow-list).
Max lines (IVariant.maxLines) — a per-variant constraint (unlike the per-product
acceptableOptions below): caps how many lines any text box can contain while this variant is
selected — additional Enter presses or pasted newlines beyond the limit are blocked and reverted,
with a brief on-canvas message shown near the text box. Pre-existing text that already exceeds a
newly-lowered limit (e.g. loaded from a saved design) is left untouched until the user next edits
that box. Unset, non-numeric, <= 0, NaN, or Infinity all mean "no limit"; non-integers are
floored (2.7 behaves as 2). Because this lives on the variant rather than the product,
switching Colour/Size/Material within the same product can change or lift/lower the limit live.
AcceptableOptions
Constrains which design options are available for a product:
interface AcceptableOptions {
colors: AcceptableColor[] // { id, name, hex }
fonts: AcceptableFont[] // { id, name }
fontDecorations: {
bold: boolean
italic: boolean
underline: boolean
}
opacityEnabled: boolean
}When acceptableOptions is provided on data.baseProduct.selected, the text and color controls automatically limit their options to the specified values. Font-size restriction and max-lines are not part of acceptableOptions — see IVariant.fontSizes and IVariant.maxLines above, both scoped per variant rather than per product.
NextCraftPricing
Configures the pricing engine:
type NextCraftPricing = {
enabled: boolean
currency: string // e.g. 'USD', '$'
drawingUnitPrice?: number | null // Price per drawing unit
textUnitPrice?: number | null // Price per text unit
drawingReferenceLengthPx?: number | null // Normalisation length for drawing paths
textReferenceFontPx?: number | null // Normalisation size for text
textBoldMultiplier?: number | null // Price multiplier for bold text (default 1.15)
debugDesignPricing?: boolean // Log pricing calculation detail to console
}NextCraftSettings
The grouped shape of the settings prop — all configuration in one place:
Every show/hide toggle inside displayOptions uses the same ShowConfig shape — { show?: boolean } — and the tree mirrors the UI hierarchy:
interface ShowConfig {
show?: boolean
}
interface NextCraftSettings {
displayOptions?: {
selectionModal?: {
search?: ShowConfig // Search input in the product picker (default: visible)
cancelButton?: ShowConfig // Modal Cancel button (default: visible)
closeButton?: ShowConfig // Modal close X button (default: visible)
// Note: the category/type filter dropdown is controlled by the top-level
// `productTypeMode` flag below, not by a displayOptions node.
}
sideBar?: {
base?: ShowConfig // Base (product selection) tab
images?: {
show?: boolean // Master switch for the Images menu
related?: {
show?: boolean // Related tab
relatedAssets?: ShowConfig // Related Assets section inside the tab
commonAssets?: ShowConfig // Common Assets section inside the tab
}
custom?: ShowConfig // Custom (upload) tab
generate?: ShowConfig // Generate (AI) tab
}
text?: ShowConfig
shapes?: {
show?: boolean // Master switch for the Shapes tab
circle?: ShowConfig
rect?: ShowConfig
triangle?: ShowConfig
star?: ShowConfig
pentagon?: ShowConfig
hexagon?: ShowConfig
}
draw?: {
show?: boolean // Master switch for the Draw tab
strokeWidth?: ShowConfig // The stroke-width slider; the pencil toggle is not hideable
}
settings?: {
show?: boolean // Settings tab (also gated by element-selection logic)
// Sections are configured PER SELECTED-OBJECT CATEGORY, so e.g. Position can be
// visible for images but hidden for text.
text?: { // Selected object is a text box
show?: boolean
typography?: {
show?: boolean
fontFamily?: ShowConfig
fontWeight?: ShowConfig
fontSize?: ShowConfig
textAlign?: ShowConfig
boldItalicUnderline?: ShowConfig
}
position?: ShowConfig
layer?: ShowConfig
opacity?: ShowConfig
fill?: ShowConfig
}
image?: { // Selected object is an image
show?: boolean
transform?: ShowConfig // Flip H/V + Rotate
position?: ShowConfig
layer?: ShowConfig
opacity?: ShowConfig
}
shape?: { // Selected object is a shape (circle/rect/triangle/star/pentagon/hexagon)
show?: boolean
position?: ShowConfig
layer?: ShowConfig
opacity?: ShowConfig
fill?: ShowConfig
}
drawing?: { // Selected object is a freehand drawing (pencil stroke)
show?: boolean
layer?: ShowConfig
opacity?: ShowConfig
fill?: ShowConfig // Applies to the stroke color
}
}
logo?: ShowConfig // Logo tab (default: visible)
}
header?: {
glow?: ShowConfig // Glow mode toggle
file?: ShowConfig // JSON import button
manageLayers?: ShowConfig // Manage Layers button
fullscreen?: ShowConfig // Fullscreen toggle button
saveProduct?: ShowConfig // Save Product button + export dropdown
}
}
variantSelectionModal?: boolean // Delegate variant selection to a modal overlay
autoSelectSingleOption?: boolean // Auto-select single available options (default: false)
productTypeMode?: boolean // Master switch for product-type (template) features (default: false)
typography?: TypographySettings // Override built-in UI text labels
pricing?: NextCraftPricing // Enable and configure the pricing engine
workspace?: {
name?: string // Workspace or brand name shown in the header
logo?: string // Logo URL shown in the header
}
customStyles?: {
button?: string // CSS declarations applied to every Button
select?: string // CSS declarations applied to the Select control
dropdown?: string // CSS declarations applied to the DropDown panel
iconCard?: string
textField?: string
textArea?: string
tooltip?: string
loader?: string
tab?: string
modal?: string
overlay?: string
selectionCard?: string
css?: string // Escape hatch: raw CSS injected into NextCraft — see Custom Styles below
}
layerOverlap?: {
enabled?: boolean // Default: false (opt-in)
categories?: ('text' | 'image' | 'shape' | 'drawing')[] // Default: ['text', 'image']
}
}Everything defaults to visible when omitted, except the category/type filter (gated by productTypeMode, see Product-Type (Template) Mode below), which is opt-in. Visibility cascades upward: the Related tab hides itself when both of its sections are hidden, and the Images menu hides itself when all three of its tabs are hidden.
TypographySettings
Override built-in UI text. The tree mirrors displayOptions 1:1 — where displayOptions nodes carry show, typography nodes carry label (plus text keys for everything inside). Every key is optional; omitted keys fall back to the built-in default text.
interface TypographySettings {
selectionModal?: {
title?: string // Modal heading (default: "Select Base Product")
searchPlaceholder?: string // default: "Search for base product..."
typeFilterLabel?: string // default: "Select Type"
typeFilterAllOption?: string // default: "All"
configureOptionsLabel?: string // default: "Configure Options"
materialLabel?: string // field label + placeholder (default: "Select Material")
colorLabel?: string // default: "Select Color"
sizeLabel?: string // default: "Select Size"
selectButton?: string // default: "Select"
cancelButton?: string // default: "Cancel"
emptyStateTitle?: string // default: "No matches for your search"
emptyStateDescription?: string
}
sideBar?: {
base?: {
label?: string // tab label (default: "Base")
selectProductButton?: string // default: "Select Base Product"
propertiesTitle?: string // default: "Properties"
materialTitle?: string // default: "Material"
colorTitle?: string // default: "Color"
sizeTitle?: string // default: "Size"
noProductPlaceholder?: string // default: "No base product is selected"
confirmButton?: string // default: "Confirm Configuration"
modifyButton?: string // default: "Modify Product"
variantChangeDialog?: { title?, description?, confirmButton?, cancelButton? }
}
images?: {
label?: string // tab label (default: "Images")
related?: {
label?: string // sub-tab label (default: "RELATED")
description?: string // default: "Add image from related to base product"
searchPlaceholder?: string // default: "Search assets..."
relatedAssetsTitle?: string // default: "Related Assets"
commonAssetsTitle?: string // default: "Common Assets"
emptyNoProduct?: string // default: "No base product is selected"
emptyNoMatch?: string // default: "No assets match your search"
emptyNoAssets?: string // default: "No assets available"
}
custom?: {
label?: string // sub-tab label (default: "CUSTOM")
description?: string // default: "Upload a custom image from your device."
addImageButton?: string // default: "Add Image"
}
generate?: {
label?: string // sub-tab label (default: "GENERATE")
description?: string // default: "Generate an image using AI (Beta)"
promptPlaceholder?: string // default: "Describe the image you want..."
generateButton?: string // default: "Generate"
generatingButton?: string // default: "Generating..."
errorFallback?: string // default: "Failed to generate image"
resultTitle?: string // default: "Result"
historyTitle?: string // default: "Previously Generated"
editColorsTitle?: string // default: "Edit Colors"
svgEditorTitle?: string // default: "SVG Editor"
backButton?: string // default: "← Back"
addToCanvasButton?: string // default: "Add to Canvas"
}
}
text?: {
label?: string // tab label (default: "Text")
addTextButton?: string // default: "Select Text" (inserts a text box)
defaultTextContent?: string // text inserted on click (default: "Hello World")
}
shapes?: {
label?: string // tab label (default: "Shapes")
sectionTitle?: string // PropertyCard title (default: "Shapes")
circleLabel?: string // aria-label, default: "Add circle shape"
rectLabel?: string
triangleLabel?: string
starLabel?: string
pentagonLabel?: string
hexagonLabel?: string
}
draw?: {
label?: string // tab label (default: "Draw")
strokeWidthLabel?: string // default: "STROKE WIDTH"
pencilToggleLabel?: string // aria-label, default: "Toggle pencil"
}
settings?: {
label?: string // tab label (default: "Settings")
emptyState?: string // default: "Select an element to view settings"
// Mirrors displayOptions.sideBar.settings: worded per selected-object category, with
// reusable section shapes. Section shapes:
// position: { sectionTitle?, topButton?, middleButton?, bottomButton?,
// leftButton?, centerButton?, rightButton? }
// layer: { sectionTitle?, moveUpButton?, moveDownButton? }
// opacity: { sectionTitle? }
// fill: { sectionTitle? }
text?: {
typography?: {
sectionTitle?: string // default: "TYPOGRAPHY"
fontFamilyLabel?: string // placeholder, default: "Select font"
fontWeightLabel?: string // placeholder, default: "Select font weight"
fontSizeLabel?: string // placeholder, default: "Select font size"
}
position?: PositionSectionTypography
layer?: LayerSectionTypography
opacity?: OpacitySectionTypography
fill?: FillSectionTypography
}
image?: {
transform?: {
sectionTitle?: string // default: "TRANSFORM"
flipHorizontalButton?: string // default: "Flip Horizontal"
flipVerticalButton?: string // default: "Flip Vertical"
rotateButton?: string // default: "Rotate"
}
position?: PositionSectionTypography
layer?: LayerSectionTypography
opacity?: OpacitySectionTypography
}
shape?: {
position?: PositionSectionTypography
layer?: LayerSectionTypography
opacity?: OpacitySectionTypography
fill?: FillSectionTypography
}
drawing?: {
layer?: LayerSectionTypography
opacity?: OpacitySectionTypography
fill?: FillSectionTypography
}
}
logo?: {
label?: string // tab label (default: "Logo")
description?: string // default: "Upload your logo image."
addImageButton?: string // default: "Add Image"
}
}
header?: {
glow?: {
label?: string // default: "Glow"
tooltipEnter?: string // default: "Preview with neon glow effect"
tooltipExit?: string // default: "Exit Glow Mode"
tooltipDisabled?: string // default: "Glow Mode is disabled"
tooltipNoProduct?: string // default: "Select a base product to use Glow Mode"
}
file?: {
label?: string // default: "File"
importDialog?: { title?, description?, confirmButton?, cancelButton? }
}
manageLayers?: { label?: string } // default: "Manage Layers"
saveProduct?: {
label?: string // default: "Save Product"
exportJson?: string // default: "As a JSON file"
exportPng?: string // default: "As a PNG file"
exportJpeg?: string // default: "As a JPEG file"
exportSvg?: string // default: "As a SVG file"
}
fullscreen?: {
tooltipEnter?: string // custom strings used as-is (no shortcut interpolation)
tooltipExit?: string
tooltipUnsupported?: string // default: "Fullscreen is not supported in this browser"
}
undoTooltip?: string // default: "Undo"
redoTooltip?: string // default: "Redo"
}
}The Fullscreen button has no visible text label (icon + tooltip only), so it has tooltip keys but no label — use displayOptions.header.fullscreen to control its visibility.
NextCraftHandle
The imperative handle exposed on <NextCraft ref={...} />, for pulling data out of the editor programmatically instead of driving it purely through props:
interface NextCraftHandle {
getJson: () => Promise<NextCraftCanvasExport | null>
}Most consumers won't need this directly — see useNextCraftRef() below, the recommended convenience wrapper, and Getting JSON Programmatically for a usage example. Both NextCraftHandle and NextCraftCanvasExport are exported from the package root.
UseNextCraftRefResult
Returned by useNextCraftRef() — see Getting JSON Programmatically:
interface UseNextCraftRefResult {
ref: RefObject<NextCraftHandle | null>
getJson: () => Promise<NextCraftCanvasExport | null>
}IAssets
Image asset shape used by every AssetCollection in data.assets (related, common, generated):
interface IAssets {
id?: string
name?: string
imageURL?: string
price?: number | null // Per-use price tracked by the pricing engine
sku?: string // External catalog SKU for this asset
webId?: string // External web/order-system identifier for this asset
}Features
Pricing Engine
Enable by passing settings={{ pricing: { enabled: true, currency: 'USD', ... } }}. When active, the editor tracks every canvas element and displays a draggable Price Panel overlay that shows an itemised cost breakdown:
- Assets / Images — priced at
IAssets.priceper placement - Shapes — priced per shape type via a configurable unit price table
- Text objects — priced using
textUnitPrice, normalised againsttextReferenceFontPx, with an optionaltextBoldMultiplier - Freehand drawings — priced using
drawingUnitPrice, normalised againstdrawingReferenceLengthPxand stroke width
The Price Panel lets users remove individual design elements and shows a running total that includes the base variant price.
Set debugDesignPricing: true to log detailed pricing calculations to the browser console during development.
AI Vector Art Generation
Provide actions.ai.onGenerateVectorArt to power the Generate tab in the Images sidebar:
<NextCraft
actions={{
ai: {
onGenerateVectorArt: async (prompt, { size }) => {
const result = await myAiService.generate({ prompt, size })
return {
svgUrl: result.url, // URL to an SVG image
svgText: result.svg, // Raw SVG string (used for colour extraction)
colors: result.colors // Optional: pre-extracted fill colours
}
}
}
}}
/>The library automatically extracts fill colours from the returned SVG and presents them as selectable palette swatches. Generated images are added directly to the canvas.
Sidebar Tab Visibility
Hide any combination of sidebar menus and tabs via settings.displayOptions.sideBar — all seven tabs (base, images, text, shapes, draw, settings, logo) are independently controllable. Everything defaults to visible; only an explicit show: false hides a node.
// Show only the base and text menus
<NextCraft
settings={{
displayOptions: {
sideBar: {
images: { show: false },
shapes: { show: false },
draw: { show: false },
settings: { show: false },
logo: { show: false }
}
}
}}
/>Note: hiding
baseremoves the only built-in way to open the product picker — only do this if your host triggers product selection some other way. Thesettingstab has its own additional auto-show/hide logic (it only appears when a supported element is selected), which applies on top of this toggle.
The Images menu contains three tabs, each individually controllable:
// Keep the Images menu but hide the Custom and Generate tabs
<NextCraft
settings={{
displayOptions: {
sideBar: {
images: {
custom: { show: false },
generate: { show: false }
}
}
}
}}
/>Visibility cascades upward — if all three Images tabs are hidden, the Images menu icon disappears automatically.
Shapes, Draw & Settings — Fine-Grained Configuration
Beyond hiding a whole tab, the Shapes, Draw, and Settings tabs each expose independent show/hide for the parts inside them:
<NextCraft
settings={{
displayOptions: {
sideBar: {
// Hide two individual shapes rather than the whole tab
shapes: {
star: { show: false },
hexagon: { show: false }
},
// Hide just the stroke-width slider; the pencil toggle stays
draw: {
strokeWidth: { show: false }
},
// Settings-tab sections are configured PER SELECTED-OBJECT CATEGORY, so the same
// section (e.g. Position) can be visible for one category and hidden for another.
settings: {
// Text objects: show ONLY Bold/Italic/Underline
text: {
typography: {
fontFamily: { show: false },
fontWeight: { show: false },
fontSize: { show: false },
textAlign: { show: false },
boldItalicUnderline: { show: true }
},
position: { show: false },
layer: { show: false },
opacity: { show: false },
fill: { show: false }
},
// Images: keep Position, hide the rest — independent of the text config above
image: {
transform: { show: false },
position: { show: true },
layer: { show: false },
opacity: { show: false }
},
// Shapes: Position + Fill only
shape: {
position: { show: true },
layer: { show: false },
opacity: { show: false },
fill: { show: true }
},
// Drawings: Fill (stroke color) only
drawing: {
layer: { show: false },
opacity: { show: false },
fill: { show: true }
}
}
}
}
}}
/>The Settings tab resolves the selected object to one of four categories — text (text boxes), image, shape (circle/rect/triangle/star/pentagon/hexagon), or drawing (pencil strokes) — and renders only that category's configured sections. Each category is independently configurable, and its wording lives at the mirrored path settings.typography.sideBar.settings.<category>. A section composes with the existing acceptableOptions per-control gating (colors/fonts/fontDecorations/opacityEnabled) — both must allow a control for it to render/enable.
Logo Tab
A dedicated tab for uploading a logo image — structurally identical to the Images → Custom tab (a description and a single upload button, no sub-tabs), visible by default alongside the other six tabs:
<NextCraft
settings={{
typography: {
sideBar: {
logo: {
description: 'Upload your company logo (PNG, JPG, or SVG).',
addImageButton: 'Upload Logo'
}
}
}
}}
/>Unlike the Custom tab, logo uploads get their own placement region: a fixed-ratio zone anchored to the bottom-right of the main design area (marked by a dashed guide). The logo is scaled to fit that region, stays clamped inside it when dragged/scaled/rotated, and there is a single logo slot — uploading a new logo replaces the previous one. The region repositions automatically on window resizes and product changes. Hide the tab with settings.displayOptions.sideBar.logo = { show: false }.
Header Button Visibility
The top-bar controls (Glow, File, Manage Layers, Fullscreen, Save Product) are each independently controllable via settings.displayOptions.header:
// Hide the Glow toggle and Manage Layers button
<NextCraft
settings={{
displayOptions: {
header: {
glow: { show: false },
manageLayers: { show: false }
}
}
}}
/>Wording (Typography)
Every user-visible text — sidebar tab labels, asset section titles, modal texts, buttons, placeholders, empty states, confirm dialogs, export items, and tooltips — can be overridden via settings.typography. The tree mirrors displayOptions 1:1, and any omitted key falls back to the built-in default text:
<NextCraft
settings={{
typography: {
selectionModal: {
title: 'Select Safety Sign',
selectButton: 'Add to Design',
materialLabel: 'Choose Material'
},
sideBar: {
base: { label: 'Product', selectProductButton: 'Select Safety Sign' },
images: {
label: 'Gallery',
related: {
label: 'SIGNS',
relatedAssetsTitle: 'Sign Assets',
commonAssetsTitle: 'Shared Assets'
}
}
},
header: {
file: { label: 'Import' },
saveProduct: { label: 'Export', exportPng: 'Download as PNG' },
undoTooltip: 'Step back'
}
}
}}
/>See the TypographySettings reference in Key Types for the complete list of overridable texts.
Custom Styles
Restyle common elements (buttons, the select control, tabs, cards, and more) from the host app via settings.customStyles — no class names or selectors to look up. Give a plain CSS declaration string (the part that would normally go between { }) to the element you want to change, and NextCraft applies it for you:
<NextCraft
settings={{
customStyles: {
button: 'border-radius: 999px; text-transform: uppercase;',
select: 'border-width: 2px;'
}
}}
/>Every key is optional and every property on it is discoverable via autocomplete on settings.customStyles in your editor — no need to inspect the DOM or read further to use this.
Element reference
| Key | Applies to |
|---|---|
| customStyles.button | Every Button, any appearance/size |
| customStyles.select | The Select dropdown control |
| customStyles.dropdown | The generic DropDown panel |
| customStyles.iconCard | IconCard |
| customStyles.textField | TextField |
| customStyles.textArea | TextArea |
| customStyles.tooltip | The Tooltip bubble |
| customStyles.loader | Loader |
| customStyles.tab | Tab |
| customStyles.modal | Modal |
| customStyles.overlay | Overlay |
| customStyles.selectionCard | SelectionCard |
Each key applies to every instance of that element regardless of appearance/size/state — e.g. customStyles.button styles all buttons, not just primary ones. If a declaration doesn't win against a more specific built-in rule, add !important to it.
Retheme everything at once with CSS variables
For a global brand retheme (not just one element type), override the library's --nc-* CSS variables using the css escape hatch below — every built-in color reads from these, so changing a handful reskins buttons, the select control, focus rings, and menus together:
<NextCraft
settings={{
customStyles: {
css: `
.next-craft {
--nc-brand-primary-600: #6d28d9;
--nc-brand-primary-500: #7c3aed;
--nc-neutral-300: #d4d4d8;
}
`
}
}}
/>| Variable | Default | Used for |
|---|---|---|
| --nc-font-family | 'DM Sans', sans-serif | Base font family |
| --nc-brand-primary-50/200/400/500/600/800 | blue scale, 600 = #2563eb | Primary buttons, focus borders, selected states (Select, options) |
| --nc-semantic-success-50/200/400/500/600 | green scale | Success states |
| --nc-semantic-danger-50/200/400/500/600 | red scale | Danger buttons, destructive states |
| --nc-semantic-warning-50/200/400/500/600 | yellow scale | Warning states |
| --nc-semantic-info-50/200/400/500/600 | cyan scale | Info states |
| --nc-neutral-50…900 | gray scale | Borders, backgrounds, text across most elements |
| --nc-neutral-dark, --nc-neutral-thick | #18191A, #252627 | Dark surfaces (e.g. IconCard active state) |
| --nc-mono-white, --nc-mono-black | #fff, #020617 | Pure white/black surfaces and text |
| --nc-select-control-bg-disabled | #f3f4f6 | Select control background when disabled |
| --nc-select-control-border | #9e9e9e | Select control border (default) |
| --nc-select-control-border-disabled | #d1d5db | Select control border (disabled) |
| --nc-select-text | #1a1a1a | Select text (value, options, active dropdown indicator) |
| --nc-select-text-disabled | #9ca3af | Select text when disabled |
| --nc-select-menu-border | #e5e7eb | Select dropdown menu border |
| --nc-select-indicator | #6b7280 | Select dropdown arrow icon (default state) |
Advanced: full CSS escape hatch
For anything the element keys above don't reach — sub-parts (e.g. the select menu/options), pseudo-states, or media queries — customStyles.css accepts full CSS text, injected as-is (not scoped for you, so prefix your own selectors):
<NextCraft
settings={{
customStyles: {
css: `
.next-craft .nc-select__menu {
border-radius: 12px;
}
.next-craft .nc-select__option--is-focused {
background: #ede9fe;
}
`
}
}}
/>This tier does require knowing the internal class name you want — the ones most hosts reach for:
- Button (
src/components/atoms/Button/Button.tsx): root.button; appearance.button--{solid-primary,solid-orange,outline-gray,ghost-gray,link-primary}; size.button--{xs,sm}; state.button--icon-only,.button--loading; inner parts.button__inner-div,.button__inner-div__loading-icon. - Select (
classNamePrefix='nc-select'):.nc-select__control(+--is-focused,--is-disabled),.nc-select__menu,.nc-select__menu-list,.nc-select__option(+--is-selected,--is-focused,--is-disabled),.nc-select__placeholder,.nc-select__single-value,.nc-select__input,.nc-select__value-container,.nc-select__indicators-container,.nc-select__dropdown-indicator. - DropDown:
.dropdownroot,.open/.closedstate. - IconCard:
.icon-cardroot,.icon-card--selected,.icon-card--disabled,.icon-card__title. - TextField:
.text-fieldroot,.text-field__wrapper,.text-field__input-field(+__customize/__non-customize,__left/__center/__right),.text-field__textBefore/__textAfter,.text-field__search-icon. - TextArea:
.text-arearoot,.text-area__input. - Tooltip:
.tooltip-wrapper(trigger),.tooltip-box(portaled todocument.body/fullscreen element, but wrapped in its own.next-craftdiv, so.next-craft .tooltip-box { ... }still reaches it),.tooltip-{top,bottom,left,right},.tooltip-visible. - Loader:
.loaderroot,.spinner. - Tab:
.tab-containerroot,.tab-container__tab-navigation,.tab-container__tab-button(+.active),.tab-container__tab-content. - Modal:
.modalroot; size.modal--{xs,sm,md,lg,xl};.modal--is-inline;.modal__header(+.modal__header__title,.modal__header__close),.modal__body,.modal__footer. - Overlay:
.overlayroot,.overlay--is-inline. - SelectionCard:
.selectionCardroot,.selectionCard-selected,.selectionCard--disabled,.selectionCard__chip(+__icon),.selectionCard__text.
Auto-Select Single Options
Opt-in UX improvement: when a variant dimension (Material / Colour / Size) has exactly one available option, it is selected automatically — in both the sidebar property cards and the variant selection modal. The behavior cascades: picking the only Material can narrow Colour to a single option, which is then picked too. When the product catalog contains exactly one product, its card is pre-selected in the modal.
<NextCraft
settings={{ autoSelectSingleOption: true }}
/>Auto-select fires once per product — if the user manually clears an auto-picked value, it is not re-forced. Existing isDefaultSelect / host defaultVariant pre-selection is untouched; auto-select only fills dimensions that are still empty. This re-fires correctly on every product you select in the modal, not just the first one — switching from one product to another re-evaluates and applies each new product's own single-option dimensions independently.
Variant Selection Modal
By default, variant pickers (colour, material, size) appear inline in the Base Product sidebar tab. Setting settings.variantSelectionModal: true moves all variant selection into a dedicated modal overlay — useful when you want a guided, step-by-step selection experience.
<NextCraft
settings={{ variantSelectionModal: true }}
/>Selection Modal Options
The product selection modal is configured via settings.displayOptions.selectionModal (its texts live at the mirrored path settings.typography.selectionModal):
<NextCraft
settings={{
displayOptions: {
selectionModal: {
search: { show: true }, // Free-text search input (default: visible)
cancelButton: { show: false }, // Hide the Cancel button (default: visible)
closeButton: { show: false } // Hide the close X (default: visible)
}
}
}}
actions={{
baseProduct: {
onSearch: query => fetchProducts({ query })
}
}}
/>The Select (confirm) button's label is configurable via typography, but it cannot be hidden — the modal must remain confirmable. The category/type filter dropdown that used to live here is now controlled by settings.productTypeMode — see Product-Type (Template) Mode.
Product-Type (Template) Mode
settings.productTypeMode: boolean (default: false, opt-in) is the master switch for every product-type/template feature:
- It directly drives the visibility of the base-product picker's category/type filter dropdown —
trueshows it,falsehides it. There is no longer a separatedisplayOptions.selectionModal.typeFilterflag to keep in sync. - It's the signal a host uses to decide whether to fetch and merge product-type-scoped assets into the Related Assets section (see below).
<NextCraft
settings={{ productTypeMode: true }}
/>Type filtering itself uses the typeId/typeName fields already present on each item in data.baseProduct.list (populated from the products → product_types join) — no extra wiring needed once productTypeMode is on.
Product-type assets
Some assets belong to a product type/template rather than to one specific product — e.g. every product under "Warning Signs" should offer the same icon set. These live in their own table, product_type_assets, scoped by product_type_id (mirrors related_assets/common_assets, just with the FK swapped):
create table if not exists product_type_assets (
asset_id uuid primary key default gen_random_uuid(),
product_type_id uuid not null references product_types (id) on delete cascade,
name text not null,
image_url text not null,
price numeric,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists product_type_assets_product_type_id_idx
on product_type_assets (product_type_id);
alter table product_type_assets enable row level security;
create policy "anon read product_type_assets"
on product_type_assets for select to anon using (true);getProductDetails now also embeds product_types(id, name), so a fetched product carries typeId/typeName (previously only the flat data.baseProduct.list items had these). The demo backend exposes getProductTypeAssets(productTypeId), shaped identically to getRelatedAssets/getCommonAssets.
There is no separate data.assets.* bucket for template assets — per the "merge into Related Assets" design, the host fetches product_type_assets alongside related_assets and concatenates them before handing the array to data.assets.related.items:
const items = productTypeMode
? [...(relatedAssets ?? []), ...(productTypeAssets ?? [])]
: relatedAssets
<NextCraft
data={{ assets: { related: { items, loading: relatedLoading || productTypeLoading } } }}
settings={{ productTypeMode: true }}
/>See src/demo/demo.tsx for the full reference wiring (PRODUCT_TYPE_MODE, handleProductTypeAssets, mergedRelatedAssets).
Asset Section Visibility
The Related tab (inside the Images menu) contains two independent asset sections. Each can be shown or hidden individually via its parent tab config, settings.displayOptions.sideBar.images.related:
<NextCraft
settings={{
displayOptions: {
sideBar: {
images: {
related: {
relatedAssets: { show: true }, // Product-specific assets (default: visible)
commonAssets: { show: false } // Workspace-wide shared assets (default: visible)
}
}
}
}
}}
/>Setting show: false removes that section entirely from the Related tab. If both sections are hidden, the Related tab itself disappears — and if the Custom and Generate tabs are also hidden, the whole Images menu is removed from the sidebar.
Layer Overlap Prevention
Opt-in constraint: layers of configured categories can never overlap each other on the canvas —
including two layers of the same category (e.g. text can't sit on text). Enable it via
settings.layerOverlap:
<NextCraft
settings={{
layerOverlap: {
enabled: true,
categories: ['text', 'image'] // default when omitted — also accepts 'shape', 'drawing'
}
}}
/>While dragging a layer over a spot that would overlap another configured layer, the overlapping area is tinted red with a centered "Can't place here" label directly on the layer; dropping it there instead snaps it to the nearest free position of the same size. The same check runs when a brand-new text/image is added from the sidebar, so it never lands directly on top of an existing layer either.
If no free position exists anywhere in the workspace (the canvas is packed), the layer is left in
place with a persistent version of the same red-tint marker instead of silently overlapping. While
any layer is in this state, the toolbar's Save/Export button (PNG, JPEG, and JSON) is disabled with
an explanatory tooltip — resolve the overlap (move a layer to free up space) to re-enable it. This
only gates the in-editor toolbar; a host calling the imperative getJson() API directly still gets
a result unconditionally.
Disabled by default — existing designs and layer stacking behave exactly as before unless you opt in.
Disable Text Resizing
Opt-in constraint: text objects can no longer be resized by dragging their canvas handles. Move,
rotate, delete, and the font-size dropdown (including the per-product hard lock — see
AcceptableOptions) all keep working; only the drag-to-scale handles are
affected, and only on text — images and shapes resize as before. Enable it via
settings.disableTextResize:
<NextCraft
settings={{
disableTextResize: true
}}
/>Disabled by default — existing designs and text resizing behave exactly as before unless you opt in.
Clip Path Content-Type Restrictions
A product variant's clip path (clipPath.shape on IVariant) can define one or more
independent clip paths — separately-restrictable regions, each with its own list of which content
types (text / image / shape / drawing) may be placed inside it. This works the same whether
the variant defines a single restricted region or several — the restriction isn't limited to
multi-region variants.
clipPath.shape stays the same flat JSON array of shape primitives it's always been — no wrapper,
no version key. Each shape optionally carries clip-path metadata:
interface ClipPathShape {
type: 'rectangle' | 'circle' | 'ellipse' | 'polygon' | 'star'
// ...geometry fields for that type (x/y/width/height, centerX/centerY/radius, points, etc.)...
/** Shapes sharing a clipPathId compose one independent clip path. Omitted = joins the implicit
* 'default' clip path — today's behavior: one combined, unrestricted region. */
clipPathId?: string
clipPathName?: string
/** Omitted or empty = all content types allowed. Only needs to be set on one shape per
* clipPathId group. */
allowedTypes?: ('text' | 'image' | 'shape' | 'drawing')[]
/** Flags this shape's clip path as the one add actions target before the user clicks a
* specific clip path. */
isDefaultClipPath?: boolean
}Example — a sign with an unrestricted main panel, an image-only pictogram area, and a text-only label area:
const shape = JSON.stringify([
{ type: 'rectangle', x: 42, y: 352, width: 1104, height: 500,
clipPathId: 'main-panel', isDefaultClipPath: true },
{ type: 'rectangle', x: 334, y: 40, width: 520, height: 260,
clipPathId: 'pictogram-area', clipPathName: 'Pictogram Area', allowedTypes: ['image'] },
{ type: 'rectangle', x: 334, y: 893, width: 520, height: 260,
clipPathId: 'bottom-label', clipPathName: 'Bottom Label', allowedTypes: ['text'] },
])Behavior:
- Click-to-target — clicking inside a clip path's bounds marks it active, so the next sidebar "Add" action or freehand stroke targets that clip path. When a variant defines more than one clip path, each renders a persistent on-canvas border (solid when active, dashed otherwise) so it's clear which one is targeted; a single clip path renders its usual border with no active/inactive distinction, since there's nothing to disambiguate.
- Rejection — adding, dragging, or drawing a disallowed content type into a clip path is blocked with the same red-tint-and-label feedback used by Layer Overlap Prevention; nothing is added, or the object snaps back to its last valid position. This applies even when the variant defines only one clip path.
- The logo (uploaded separately from regular content) is unaffected by this feature — it always anchors to the bottom-right of the default clip path.
- A variant whose shapes never set
clipPathIdbehaves exactly as before — one combined, unrestricted clip path. Fully backward compatible and opt-in; no authoring UI, the shape data is provided the same wayclipPath.shapealready is today.
JSON Import / Export
The editor exposes save/load JSON functionality through the toolbar. A saved JSON snapshot encodes the full canvas state including all objects, their positions, styles, and asset references. On import, the library automatically:
- Detects the product ID embedded in the snapshot
- Calls
actions.baseProduct.onSubmitto load the matching base product - Restores the canvas and variant selection once the product is ready
Note:
__jsonImportProductIdis a reserved key on the variant object used internally during JSON import. Do not use this key in your own data.
Getting JSON Programmatically
The simplest way to pull the current design out as JSON in your own code (e.g. to save it to your backend instead of triggering the toolbar's file download) is useNextCraftRef() — it owns the ref for you and hands back a ready-to-call getJson():
import { NextCraft, useNextCraftRef } from '@digital_400/next-craft'
function Editor() {
const { ref, getJson } = useNextCraftRef()
async function handleSave() {
const json = await getJson()
if (!json) return // editor not initialized yet
await fetch('/api/designs', { method: 'POST', body: JSON.stringify(json) })
}
return <NextCraft ref={ref} ... />
}getJson() returns the same payload the toolbar's "Save" → "As a JSON file" option downloads to disk (version, canvasData, clipPathData, productData, assetsData), just handed back to your code instead of downloaded. It resolves to null if called before the editor has initialized.
Manual ref (advanced)
Use the raw ref directly if you need more than getJson() — composing it into your own imperative handle, forwarding it through another component, etc. useNextCraftRef() above is a thin wrapper around exactly this:
import { useRef } from 'react'
import { NextCraft, type NextCraftHandle } from '@digital_400/next-craft'
const nextCraftRef = useRef<NextCraftHandle>(null)
async function handleSave() {
const json = await nextCraftRef.current?.getJson()
if (!json) return // editor not initialized yet
await fetch('/api/designs', { method: 'POST', body: JSON.stringify(json) })
}
<NextCraft ref={nextCraftRef} ... />AcceptableOptions
Pass acceptableOptions on data.baseProduct.selected to constrain the design options available for that product. When set, the text editor's font selector shows only the listed fonts, the colour picker shows only the listed swatches, bold/italic/underline controls are gated by fontDecorations, and the opacity slider is shown or hidden based on opacityEnabled.
const product: ISelectedProduct = {
id: 'prod-1',
productName: 'Classic T-Shirt',
variants: [
{
varientId: 'v-1',
Colour: 'Black',
Size: 'M',
// Variant-scoped, not part of acceptableOptions — see IVariant below.
fontSizes: [24, 32, 48],
maxLines: 3,
},
// ...other variants, each with their own optional fontSizes/maxLines...
],
acceptableOptions: {
colors: [
{ id: 'c1', name: 'Black', hex: '#000000' },
{ id: 'c2', name: 'White', hex: '#ffffff' },
],
fonts: [
{ id: 'f1', name: 'Arial' },
{ id: 'f2', name: 'Georgia' },
],
fontDecorations: { bold: true, italic: false, underline: false },
opacityEnabled: true,
}
}See IVariant above for the font-size hard-lock and max-lines behavior — both are
per-variant constraints now (unlike the per-product-type "Caps only" rule below), so switching
Colour/Size/Material within this same product can change or re-lock the available sizes, or
change/lift the line limit, live.
Product-Type (Template) Text Options
The text controls (fonts, font weights, fill colours, and a forced Caps only rule) can be driven by the product's product type / template. This is a third, opt-in layer on top of the two existing ones:
- General — the global show/hide flags in
settings.displayOptions.sideBar.settings.text.typography.*. - Product-specific —
acceptableOptionson the selected product (see above). - Product-type / template —
productType.textOptionson the selected product (this feature).
Attach the type config to the product the same way acceptableOptions rides on it — your app
resolves it (e.g. from your backend) and passes it in:
const product: ISelectedProduct = {
id: 'prod-1',
productName: 'Danger Sign',
variants: [...],
productType: {
id: 'danger-signs',
name: 'Danger Signs',
textOptions: {
// Mark ONE item in each list with `default: true` to set the default applied to NEW text.
fonts: [
{ id: 'f1', name: 'Arial', default: true },
{ id: 'f2', name: 'Impact' },
],
// Data-driven weight presets — you pick the labels and numeric CSS weights.
fontWeights: [
{ label: 'Narrow', value: 300 },
{ label: 'Normal', value: 400, default: true },
{ label: 'Bold', value: 700 },
],
colors: [
{ id: 'c1', name: 'Black', hex: '#000000', default: true },
{ id: 'c2', name: 'Safety Yellow', hex: '#f5d000' },
],
capsOnly: true, // all text is forced to uppercase — the user cannot override it
},
},
}Resolution mode — choose how the template layer combines with the others via
settings.textOptions.mode:
<NextCraft settings={{ textOptions: { mode: 'templateOnly' } }} />'merged'(default): the type's fonts/colours are layered on top of the general + product-specific options; the type's weights replace the default set when provided.'templateOnly': the font/weight/colour dropdowns show only the product type's configured options. A product with noproductType.textOptionsfalls back to'merged', so nothing breaks.
Notes:
capsOnlyis enforced in both modes whenever the type sets it — new text is created uppercase and typed characters are kept uppercase.- Default for new text — mark one item in
fonts/fontWeights/colorswithdefault: trueto set what a newly added text box uses (font family / weight / fill). Resolution is: the flagged item → else the first item in that list → else the built-in default (Arial / 400 / first acceptable colour or black). This applies even when the pickers are hidden, so atemplateOnlyproduct with hidden controls still produces template-correct text. - The general show/hide flags still apply independently: a control hidden via
displayOptionsstays hidden regardless of the template. To see the template fonts/weights/colours, keepfontFamily/fontWeight/fillvisible. - With no
productType, behaviour is identical to before — the feature is fully backward compatible and opt-in.
Demo backend (Supabase)
The bundled demo sources the type config from Supabase. Fonts and colors are shared master
libraries (reused across product types); each product type links to the subset it offers via
junction tables. Font weights stay per-type (the label is intentionally customizable per type —
e.g. "Narrow" for 300 on one type, "Light" for the same value on another). The scalar caps-only
flag is a plain caps_only column directly on product_types — a single boolean doesn't warrant
its own table/join/RLS policy the way a list-shaped feature does.
-- Shared font/color libraries, reused across product types
create table if not exists fonts (
id uuid primary key default gen_random_uuid(),
name text not null,
created_at timestamptz not null default now(),
constraint fonts_name_unique unique (name)
);
create table if not exists colors (
id uuid primary key default gen_random_uuid(),
name text not null,
hex text not null,
created_at timestamptz not null default now(),
constraint colors_hex_unique unique (hex)
);
-- Which fonts/colors a product type offers, and which one is default
create table if not exists product_type_fonts (
product_type_id uuid not null references product_types (id) on delete cascade,
font_id uuid not null references fonts (id) on delete cascade,
is_default boolean not null default false,
sort_order int not null default 0,
primary key (product_type_id, font_id)
);
create unique index if not exists product_type_fonts_one_default
on product_type_fonts (product_type_id) where is_default;
create table if not exists product_type_colors (
product_type_id uuid not null references product_types (id) on delete cascade,
color_id uuid not null references colors (id) on delete cascade,
is_default boolean not null default false,
sort_order int not null default 0,
primary key (product_type_id, color_id)
);
create unique index if not exists product_type_colors_one_default
on product_type_colors (product_type_id) where is_default;
-- Per-type font-weight options (value is the standard CSS 100-900 scale; label is type-specific)
create table if not exists product_type_font_weights (
product_type_id uuid not null references product_types (id) on delete cascade,
value smallint not null check (value between 100 and 900 and value % 100 = 0),
label text not null,
is_default boolean not null default false,
sort_order int not null default 0,
primary key (product_type_id, value)
);
create unique index if not exists product_type_font_weights_one_default
on product_type_font_weights (product_type_id) where is_default;
-- Scalar per-type text setting — plain column, not its own table
alter table product_types add column if not exists caps_only boolean not null default false;
-- Anon read access (the app uses the anon key). product_types itself must also be
-- anon-readable for the id/name join and the selection-modal type filter.
alter table fonts enable row level security;
create policy "anon read fonts" on fonts for select to anon using (true);
alter table colors enable row level security;
create policy "anon read colors" on colors for select to anon using (true);
alter table product_type_fonts enable row level security;
create policy "anon read product_type_fonts" on produc