diff --git a/AGENTS.md b/AGENTS.md index e6ae688..d2c5b37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,10 +40,11 @@ The app includes **Agent**, an in-app AI assistant, and a local **MCP server** i - `src/lib/persistence/projects.ts`: create a project, snapshot one workspace, reopen a recent project (values through `initialProperties`) - `src/lib/persistence/recents.ts`: recent projects and their thumbnails (IndexedDB), recorded while editing and shown on the start screen (`/new`) - `src/lib/icons/icons.ts`: the icon registry: the built-in Font Awesome set (`FaRocket`) plus the icon packs installed in the component library (`acme:cloud`). The Icon block and `@type:icon` variables of components offer both; `search_icons` reads it. `sanitize-svg.ts` cleans pack icons on import -- `src/lib/variables/variables.ts`: project variables (template slots). A workspace holds `variables`; text, code, window, QR and HTML blocks show `{{name}}` as the value through `useResolvedText` (`src/hooks/useProjectVariables.ts`) while the stored text keeps the placeholder. They are part of the canvas settings, so `setProjectVariables` is undoable; `set_variables` fills them -- `src/lib/brand/brand-kit.ts` / `src/stores/brand-store.ts`: the brand kit (colors, fonts, logos as data URLs, guidelines), kept in IndexedDB and edited in `BrandKitDialog`. The color and font pickers list it first; `get_brand_kit` and `add_brand_logo` give it to Agent and MCP clients +- `src/lib/variables/variables.ts`: project variables (template slots), edited in the Variables tab of the properties panel (`VariablesPanel`). A workspace holds `variables`; text, code, window, QR and HTML blocks show `{{name}}` as the value through `useResolvedText` (`src/hooks/useProjectVariables.ts`) while the stored text keeps the placeholder. They are part of the canvas settings, so `setProjectVariables` is undoable; `set_variables` fills them +- `src/lib/brand/brand-kit.ts` / `src/stores/brand-store.ts`: the brand kit (colors, fonts, logos as data URLs, guidelines), kept in IndexedDB and edited with `BrandKitEditor` (`src/components/Brand/`), shown in the Brand kit tab of the properties panel and, outside the editor, in `BrandKitDialog`. The color and font pickers list it first; `get_brand_kit` and `add_brand_logo` give it to Agent and MCP clients, and `update_brand_kit` / `save_brand_logo` let them write it (not part of the canvas undo) - `src/lib/agent/core/design-guide.ts`: the design standards Agent and MCP clients follow (in the system prompt, the MCP instructions and `get_design_guide`) - `src/lib/templates/starters.ts`: built-in templates on the start screen; each one is a size, a background and `addBlock` inputs (keys from `catalog.ts`) +- `src/components/Panels/RightPanel.tsx`: the properties panel and its tabs (hierarchy, control, workspace, variables, brand kit). Each tab is a `TabPage`: a title and a scroll area that takes only the height left (`min-h-0 flex-1`, never `h-full`, or the end of a long tab is cut off). `ui-store`'s `selectedTab` switches it from elsewhere - `src/components/CustomControls/PropertyControls.tsx`: `PropertyRow`, `FieldInput`, `SliderField`, `ToggleGroup` / `ToggleButton` — use them for block property menus so rows line up - `src/lib/editor/`: editor actions with arguments (`actions.ts`) and undo/redo helpers (`history.ts`) - `src/lib/editor/layout.ts`: panel layouts (canvas, properties, agent, both). The layout is just `propertiesOpen` (`ui-store`, persisted) plus the agent's `panelOpen`; the status bar button and `view.layout-*` commands switch it. The agent docks as its own column at the left edge; the properties panel floats over the right edge of the canvas @@ -110,6 +111,7 @@ Agent (assistant panel, `Mod+L`) and the MCP server share one set of tools. Full - **History**: batch entries can mix property changes, `workspace-structure-*` snapshots and `workspace-settings-*` snapshots. Apply undo/redo with `undo()`/`redo()` from `src/lib/editor/history.ts`, which handles all of them. - **Block catalog** (`src/lib/blocks/catalog.ts`): mirrors the `useControlState` keys and defaults of each block. Update it when a block gains or renames a property. - **Component library** (`src/lib/agent/tools/components.ts`): `list_components`, `import_component`, `add_component`, `export_component` and `load_starter_pack` read and write the `.kcomponent` library (`src/stores/kcomponent-store.ts`, persisted) and put components on the canvas through `addBlock`. Adding one goes through `kcomponentBlockInput()` so an empty `css`/`js` section does not fall back to the demo content of a blank HTML block. +- **HTML block contract** (`src/lib/agent/tools/html-contract.ts`): HTML blocks written through `add_block`, `update_block` or `update_html_block` must have annotated `:root` CSS variables and `// @var` JS variables; the tools refuse other code with what to fix and turn `allow-scripts` on. `html-hints.ts` adds softer hints (too few variables, too big, too much text). - **Command allowlist** (`src/lib/agent/tools/commands.ts`): `run_command` only runs `edit.*`, `arrange.*`, `view.*` and individually vetted ids. `tools.*` stays out on purpose (a model cannot drag on the canvas, so picking a tool would only strand the editor in a mode); dialogs, saving and `workspace.clean` stay out too. Widen it there, with the reason in the comment. - **Providers** (`src/lib/agent/providers/`): pure adapters (Anthropic Messages, OpenAI Chat Completions, Gemini) that build requests and parse SSE streams; presets add base URLs and hints. The UI never depends on the provider. - **Transports**: the web uses `fetch` from the page (the provider must allow CORS). Electron sends requests from the main process (`src-electron/agent/http.ts`), which adds the API key; keys are encrypted with `safeStorage` and bound to the origin they were saved for. Never log requests, headers or keys; use `redactSecrets()` for error text. diff --git a/CHANGELOG.md b/CHANGELOG.md index e69aec5..1fc4b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## v 2.1.0 - Release (September 29th, 2026) + +### 🚀 Features + +- **Brand kit panel**: the brand kit has its own tab in the properties panel, next to the workspace settings and organized like them, in collapsible sections (name, colors, fonts, logos, guidelines) that show how many items each holds; File → **Brand kit…** opens it (outside the editor it still opens in a dialog) +- **Agent and MCP clients create and edit the brand kit**: `update_brand_kit` sets the name, palette, fonts by role (checked against Google Fonts), logo names and uses and the guidelines; `save_brand_logo` adds a logo from SVG markup, a data URL or an image block of the canvas. They do it when the user gives them their brand or asks for one +- **Variables tab**: project variables moved out of the workspace settings to their own tab of the properties panel +- **Every HTML block Agent and MCP clients write has CSS and JS variables**: the look as annotated `:root` variables and the content (labels, values, lists) as `// @var` JS variables that the script renders, so both are edited from the panel. `add_block`, `update_block` and `update_html_block` refuse a block without them, with a script that declares a variable twice or with a list that is not a JSON list of strings, say what to fix and turn scripts on for the block +- **Easier property panel for HTML blocks**: variables read as names ("Value size", not `--value-size`) in rows that line up with the other panels; numbers take a typed value next to the slider, lists edit each item in its own field (Enter applies, reorder, remove, add), objects edit each value in place or as JSON, and the shadow editor is a compact set of rows + +### 🐛 Fixes + +- The properties panel could not scroll to its end: its scroll area took the full height of the panel under the title and the align bar, so the last part of a tab with several sections open stayed hidden +- Going to the block editor and back (or anything else that shows the canvas again) could lose block positions and other values: every block painted its defaults for a frame and wrote them over its saved values before reading them back. Blocks now start from their saved values, and a custom background of a code block survives +- The first render of a code block showed raw `
` markup before the code
+- A text block exported while selected (or under the pointer) kept its blue outline in the image
+- **Zoom to fit** and **Zoom to 100%** ignored the panels floating over the canvas: with Agent open the canvas ended up under the properties panel. They now fit and center it in the space left free
+- HTML block scripts could not build elements: `document.createElement` threw "Illegal invocation", so lists rendered from a JS array stayed empty. Editing a list whose items were numbers did nothing, and a `// @var` value followed by a semicolon read as empty
+
 ## v 2.0.0 - Release (September 28th, 2026)
 
 ### 🚀 Features
diff --git a/README.md b/README.md
index c46b24d..1165c5a 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Karbonized is a visual editor for creating images of code snippets, mockups and
 
 * **Agent, the design assistant** — describe the image you want and Agent builds it on the canvas, with Anthropic, OpenAI, Gemini, OpenRouter or local models (Ollama, LM Studio). It follows design standards made for social media, and everything it did undoes in one step.
 * **MCP server** — Claude Desktop, Claude Code, Cursor and other MCP clients can design with Karbonized. They start it in the background when it is not open, and exports land in a folder without any dialog.
-* **Templates with variables** — write `{{title}}`, `{{date}}` or `{{code}}` in any block and fill them from the Workspace panel (or let Agent do it): the next post is a new value, not a redesign.
+* **Templates with variables** — write `{{title}}`, `{{date}}` or `{{code}}` in any block and fill them from the Variables panel (or let Agent do it): the next post is a new value, not a redesign.
 * **Brand kit** — your colors, fonts, logos and guidelines in one place. The pickers offer them first and Agent reads them before designing.
 * **Drawing tools** — a vector brush that follows pen pressure, shapes drawn by dragging, node editing, an eraser, rulers and guides.
 * **Components and icon packs** — a reworked `.kcomponent` library with a starter pack, and icon packs that anyone can make from a folder of SVGs.
diff --git a/docs/agent.md b/docs/agent.md
index b4a7995..2f86c72 100644
--- a/docs/agent.md
+++ b/docs/agent.md
@@ -72,6 +72,8 @@ Agent and the MCP server share the same tools:
 | `search_icons`                         | Icon names (Font Awesome and installed icon packs) for icon blocks and `@type:icon` variables                                        |
 | `search_fonts`                         | Google Fonts families by name, kind and weights, with the weights each one has                                                       |
 | `get_brand_kit`                        | The brand kit: named colors, fonts, logos (without the images) and guidelines                                                        |
+| `update_brand_kit`                     | Create or change the brand kit: name, palette, fonts by role (checked against Google Fonts), logo names and uses, guidelines         |
+| `save_brand_logo`                      | Add a logo to the brand kit from SVG markup (cleaned of scripts), a `data:image/…` URL or an image block of the canvas                |
 | `add_brand_logo`                       | Place a logo of the brand kit as an image block, by id or variant, keeping its proportions                                           |
 | `get_workspace`                        | Canvas size, background, selection and every block with its position, size and properties                                            |
 | `create_workspace`                     | New project with a canvas size, opened in the editor                                                                                 |
@@ -110,14 +112,20 @@ Change the guide there; the prompt, the instructions and the tool follow.
 The guide asks models to build a design block by block: the background with
 `set_canvas_background`, every headline and paragraph as a text block, and one
 HTML block per component (a stat tile, a card, a badge row, a chart), sized to
-its content and declaring its colors, sizes, radius, shadow and icon as
-annotated `:root` variables. `add_block` and `update_html_block` check the
-last two rules (`src/lib/agent/tools/html-hints.ts`) and answer with `hints`
-when an HTML block declares no variables, covers most of the canvas or holds
-paragraphs of text, so the model fixes it in the same turn. Content that
-repeats (list items, chart data, rows) goes in `// @var` JS variables that the
-block script renders, with `allow-scripts` on, so the user edits the data from
-the panel; the hints flag a block whose script cannot run.
+its content.
+
+Every HTML block a model writes has html, css and js
+(`src/lib/agent/tools/html-contract.ts`): the look as annotated `:root`
+variables in the CSS and the content (labels, values, list items, chart data)
+as `// @var` JS variables that the script writes into the markup, so the user
+edits both from the panel. `add_block`, `update_block` and `update_html_block`
+refuse code without them, with a script that declares a `// @var` again, or
+with an array or object value that is not JSON (an array must be a list of
+strings, which is what the panel edits), and say what to fix; they turn
+`allow-scripts` on for the block. They also answer with `hints`
+(`src/lib/agent/tools/html-hints.ts`) when an HTML block has too few
+variables, covers most of the canvas or holds paragraphs of text, so the model
+fixes it in the same turn.
 
 Fonts: the guide sends models to `search_fonts` (the whole Google Fonts
 catalog, with the weights of each family), gives pairings by tone and asks for
@@ -133,18 +141,22 @@ The snapshot waits for pending fonts so it does not show the fallback.
 
 ### Brand kit
 
-File → **Brand kit…** (or the command palette) holds the colors, fonts,
-logos and guidelines of the user's brand. The color picker shows the brand
-colors first and the font picker the brand fonts. Agent and MCP clients are
-told to call `get_brand_kit` before a new design and to follow it over the
-palettes of the design guide; `add_brand_logo` places a logo without sending
-the image through the model. A kit can be exported and imported as a
+The **Brand kit** tab of the properties panel (also File → **Brand kit…** or
+the command palette, which open it; outside the editor it opens in a dialog)
+holds the colors, fonts, logos and guidelines of the user's brand. The color
+picker shows the brand colors first and the font picker the brand fonts. Agent
+and MCP clients are told to call `get_brand_kit` before a new design and to
+follow it over the palettes of the design guide; `add_brand_logo` places a
+logo without sending the image through the model. When the user gives them
+their brand or asks for one, they save it with `update_brand_kit` and
+`save_brand_logo` (`src/lib/agent/tools/brand.ts`); those changes are saved at
+once, show in the tab, and are not part of the canvas undo. A kit can be exported and imported as a
 `.kbrand` file (JSON). It is stored in IndexedDB (`src/stores/brand-store.ts`,
 model in `src/lib/brand/brand-kit.ts`).
 
 ### Templates and project variables
 
-A project can hold variables (Canvas panel → **Variables**): named texts,
+A project can hold variables (the **Variables** tab of the properties panel): named texts,
 long texts and dates. Any text, code, window, QR or HTML block that contains
 `{{name}}` shows the value instead; the block keeps the placeholder, so the
 same design becomes a template. Changing a value is one undo step.
diff --git a/src/components/Base/MenuBar.tsx b/src/components/Base/MenuBar.tsx
index e2640a8..0db8d6d 100644
--- a/src/components/Base/MenuBar.tsx
+++ b/src/components/Base/MenuBar.tsx
@@ -95,6 +95,17 @@ export const MenuBar: React.FC = () => {
 	const [showDonations, setShowDonations] = useState(false);
 	const [showImportComponents, setShowImportComponents] = useState(false);
 	const [showBrandKit, setShowBrandKit] = useState(false);
+	/* In the editor the brand kit has its own tab in the properties panel;
+	   elsewhere it opens in a dialog. */
+	const openBrandKit = () => {
+		if (location.pathname !== '/editor') {
+			setShowBrandKit(true);
+			return;
+		}
+		const ui = useUIStore.getState();
+		ui.setSelectedTab('brand');
+		ui.setPropertiesOpen(true);
+	};
 
 	/* KComponent Store */
 	/* The library dialog itself lives in the editor's left panel. */
@@ -328,7 +339,7 @@ export const MenuBar: React.FC = () => {
 			group: 'File',
 			icon: SwatchBook,
 			keywords: ['brand', 'colors', 'fonts', 'logo', 'palette'],
-			run: () => setShowBrandKit(true),
+			run: openBrandKit,
 		},
 		{
 			id: 'workspace.clean',
@@ -468,9 +479,7 @@ export const MenuBar: React.FC = () => {
 							
 
 							
-							 setShowBrandKit(true)}>
-								Brand Kit…
-							
+							Brand Kit…
 						
 					
 
diff --git a/src/components/Blocks/CodeBlock.tsx b/src/components/Blocks/CodeBlock.tsx
index 768bbb3..7b4d061 100644
--- a/src/components/Blocks/CodeBlock.tsx
+++ b/src/components/Blocks/CodeBlock.tsx
@@ -15,7 +15,7 @@ import {
 	IconTerminal,
 	IconX,
 } from '@tabler/icons-react';
-import React, { useEffect } from 'react';
+import React, { useEffect, useRef } from 'react';
 import { PropertyRow, SliderField } from '../CustomControls/PropertyControls';
 import { Switch } from '../ui/switch';
 import { ControlTemplate } from './ControlTemplate';
@@ -25,8 +25,9 @@ import { ColorPicker } from '../CustomControls/ColorPicker';
 import { CloseSvg, MinimizeSvg } from '../Misc/Icons';
 import { LanguajeTabIcon } from './LanguajeTabIcon';
 import { useControlState } from '../../hooks/useControlState';
+import { useControlsStore, useWorkspaceStore } from '@/stores';
 import { useResolvedText } from '../../hooks/useProjectVariables';
-import { themes } from '../../utils/PrismThemes';
+import { codeThemeBackground, themes } from '../../utils/PrismThemes';
 import { Label } from '../ui/label';
 import { Input } from '../ui/input';
 import {
@@ -46,11 +47,12 @@ const CodeControl: React.FC = ({ id }) => {
 
 	const [theme, setTheme] = useControlState('coldarkDark', `${id}-theme`);
 	const [language, setLanguage] = useControlState('jsx', `${id}-lang`);
-	const [code, setCode] = useControlState(
-		`
`, - `${id}-code`, + const [code, setCode] = useControlState('', `${id}-code`); + // A new block starts on the background of its theme. + const [color, setColor] = useControlState( + codeThemeBackground(theme) ?? '#111b28', + `${id}-bgcolor`, ); - const [color, setColor] = useControlState('#111b28', `${id}-bgcolor`); const [controlsColor, setControlsColor] = useControlState( '#b4b4b4', `${id}-ccolor`, @@ -86,18 +88,40 @@ const CodeControl: React.FC = ({ id }) => { return themes.find((value) => value.label === theme)?.theme; }; - /* Handle Change Theme Colors */ + /* A new block keeps the background it starts on, so Agent and saved + projects read the color it shows rather than the catalog default. */ useEffect(() => { - const newTheme = themes.find((value) => value.label === theme)?.theme; - - setColor( - (newTheme as any)[':not(pre) > code[class*="language-"]'].background || - (newTheme as any)[`code[class*="language-"]`].background, + const key = `${id}-bgcolor`; + const { ControlProperties, initialProperties, addControlProperty } = + useControlsStore.getState(); + const known = [...ControlProperties, ...initialProperties].some( + (item) => item.id === key, ); + if (!known) { + addControlProperty( + { id: key, value: color }, + useWorkspaceStore.getState().currentWorkspaceID, + ); + } + // Only the color the block mounted with. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + /* Picking a theme or the paper style resets the background to theme it. + Only when they change: mounting again (after the block editor, a + workspace switch, a project load) keeps the background the user chose. */ + const shownTheme = useRef(theme); + useEffect(() => { + if (shownTheme.current === theme) return; + shownTheme.current = theme; + const background = codeThemeBackground(theme); + if (background) setColor(background); }, [theme]); - /* Handle Window Style Change - Set color for paper style */ + const shownWindowStyle = useRef(windowStyle); useEffect(() => { + if (shownWindowStyle.current === windowStyle) return; + shownWindowStyle.current = windowStyle; if (windowStyle === 'paper') { setColor('#fbfaf7'); } diff --git a/src/components/Blocks/ControlTemplate.tsx b/src/components/Blocks/ControlTemplate.tsx index b381112..9aaaf8e 100644 --- a/src/components/Blocks/ControlTemplate.tsx +++ b/src/components/Blocks/ControlTemplate.tsx @@ -186,7 +186,9 @@ export const ControlTemplate: React.FC = ({ useLayoutEffect(() => { syncContentSize.current = () => { const element = document.getElementById(id); - if (!element) return; + // Not laid out: hidden while another page loads (React hides the + // canvas with display: none), so it measures 0 × 0. + if (!element || element.getClientRects().length === 0) return; const next = { w: element.offsetWidth, h: element.offsetHeight }; if (next.w === Number(size.w) && next.h === Number(size.h)) return; diff --git a/src/components/Blocks/HTMLBlock.tsx b/src/components/Blocks/HTMLBlock.tsx index 90784f8..9d4c77b 100644 --- a/src/components/Blocks/HTMLBlock.tsx +++ b/src/components/Blocks/HTMLBlock.tsx @@ -26,6 +26,7 @@ import { updateJSVariable, buildBlockStylesheet, createSafeDOM, + createScopedDocument, fileHandler, fileUtils, } from '../../lib/blocks-api'; @@ -104,42 +105,6 @@ export const HTMLBlock: React.FC = ({ id }) => { setDevLogs([]); }; - const createScopedDocument = ( - shadowRoot: ShadowRoot, - host: HTMLDivElement, - ): Document & ShadowRoot => { - const globalDocument = window.document; - - return new Proxy(globalDocument, { - get(target, prop) { - switch (prop) { - case 'querySelector': - return shadowRoot.querySelector.bind(shadowRoot); - case 'querySelectorAll': - return shadowRoot.querySelectorAll.bind(shadowRoot); - case 'getElementById': - return shadowRoot.getElementById?.bind(shadowRoot); - case 'body': - return shadowRoot; - case 'head': - return shadowRoot; - case 'documentElement': - return host; - case 'activeElement': - return shadowRoot.activeElement; - case 'addEventListener': - return shadowRoot.addEventListener.bind(shadowRoot); - case 'removeEventListener': - return shadowRoot.removeEventListener.bind(shadowRoot); - case 'dispatchEvent': - return shadowRoot.dispatchEvent.bind(shadowRoot); - default: - return Reflect.get(target, prop, target); - } - }, - }) as Document & ShadowRoot; - }; - // Update CSS content when variables change const handleUpdateCSSVariable = ( varName: string, diff --git a/src/components/Blocks/HTMLBlockBindingsPanel.tsx b/src/components/Blocks/HTMLBlockBindingsPanel.tsx index c1b1cac..c91c346 100644 --- a/src/components/Blocks/HTMLBlockBindingsPanel.tsx +++ b/src/components/Blocks/HTMLBlockBindingsPanel.tsx @@ -7,7 +7,6 @@ import { type JSVariable, } from '@/lib/blocks-api'; import { ColorPicker } from '../CustomControls/ColorPicker'; -import { Label } from '../ui/label'; import { Input } from '../ui/input'; import { Slider } from '../ui/slider'; import { Switch } from '../ui/switch'; @@ -15,6 +14,7 @@ import { ObjectEditor } from '../CustomControls/ObjectEditor'; import { ArrayEditor } from '../CustomControls/ArrayEditor'; import { ImageInput, FileInput } from '../CustomControls/FileInput'; import { ShadowEditor } from '../CustomControls/ShadowEditor'; +import { FieldInput, PropertyRow } from '../CustomControls/PropertyControls'; import { Button } from '../ui/button'; interface CSSControlsProps { @@ -36,112 +36,177 @@ interface ActionControlsProps { onExecuteAction: (action: CustomAction) => void; } +/** `--value-size` and `showValues` read as "Value size" and "Show values". */ +export const variableLabel = (name: string): string => { + const words = name + .replace(/^-+/, '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/[-_\s]+/) + .filter(Boolean) + .map((word) => word.toLowerCase()); + const text = words.join(' '); + return text.charAt(0).toUpperCase() + text.slice(1) || name; +}; + +/** The label of a row, with the variable name on hover. */ +const RowLabel: React.FC<{ name: string }> = ({ name }) => ( + {variableLabel(name)} +); + +/** + * A slider with a field to type the exact value. Values past the declared + * range stay reachable: the slider grows to fit them. + */ +const NumberField: React.FC<{ + name: string; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + unit?: string; +}> = ({ name, value, onChange, min = 0, max, step = 1, unit }) => { + const number = Number(value) || 0; + const upper = Math.max(max ?? 100, number); + const lower = Math.min(min, number); + + return ( + }> + onChange(next[0])} + /> + { + const next = Number(text); + if (text.trim() !== '' && Number.isFinite(next)) onChange(next); + }} + className='w-16 flex-none' + /> + + ); +}; + +const TextField: React.FC<{ + name: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + mono?: boolean; +}> = ({ name, value, onChange, placeholder, mono }) => ( + }> + onChange(event.target.value)} + className={`h-7 text-xs md:text-xs ${mono ? 'font-mono' : ''}`} + /> + +); + +const BooleanField: React.FC<{ + name: string; + value: boolean; + onChange: (value: boolean) => void; +}> = ({ name, value, onChange }) => ( + }> + + +); + export const HTMLBlockCSSVariablesControls: React.FC = ({ variables, onUpdateVariable, }) => ( -
+
{variables.map((variable) => { + const update = (value: string | number | boolean) => + onUpdateVariable(variable.name, value); + switch (variable.type) { case 'color': return ( -
- - - onUpdateVariable(variable.name, color) - } - label='' - /> -
+ ); case 'number': return ( -
- -
- - onUpdateVariable(variable.name, value[0]) - } - value={[variable.value as number]} - min={variable.min || 0} - max={variable.max || 100} - step={variable.step || 1} - /> - - {variable.value} - -
-
+ ); case 'boolean': return ( -
- - - onUpdateVariable(variable.name, checked) - } - /> -
+ name={variable.name} + value={variable.value as boolean} + onChange={update} + /> ); case 'icon': return ( -
- -
- onUpdateVariable(variable.name, name)} - /> -
-
+ } + > + + ); case 'shadow': return ( -
+
onUpdateVariable(variable.name, value)} - label={variable.name} + onChange={update} + label={variableLabel(variable.name)} />
); default: return ( -
- - - onUpdateVariable(variable.name, event.target.value) - } - className='h-7 font-mono text-xs' - /> -
+ ); } })} @@ -152,28 +217,25 @@ export const HTMLBlockJSVariablesControls: React.FC = ({ variables, onUpdateVariable, }) => ( -
+
{variables.map((variable) => { + const update = (value: unknown) => onUpdateVariable(variable.name, value); + switch (variable.type) { case 'color': return ( -
- - - onUpdateVariable(variable.name, color) - } - label='' - /> -
+ ); case 'gradient': { - const gradientValue = variable.value as string; + const gradientValue = String(variable.value); let color1 = '#667eea'; let color2 = '#764ba2'; let angle = 45; @@ -188,136 +250,98 @@ export const HTMLBlockJSVariablesControls: React.FC = ({ } return ( -
- - undefined} - onGradientChange={(newColor1, newColor2) => { - onUpdateVariable( - variable.name, - `linear-gradient(${angle}deg, ${newColor1}, ${newColor2})`, - ); - }} - onGradientDegChange={(newAngle) => { - onUpdateVariable( - variable.name, - `linear-gradient(${newAngle}deg, ${color1}, ${color2})`, - ); - }} - label='' - /> -
+ undefined} + onGradientChange={(newColor1, newColor2) => + update( + `linear-gradient(${angle}deg, ${newColor1}, ${newColor2})`, + ) + } + onGradientDegChange={(newAngle) => + update(`linear-gradient(${newAngle}deg, ${color1}, ${color2})`) + } + label={variableLabel(variable.name)} + /> ); } case 'number': return ( -
- -
- - onUpdateVariable(variable.name, value[0]) - } - value={[variable.value as number]} - min={variable.min || 0} - max={variable.max || 100} - step={variable.step || 1} - /> - - {variable.value as any} - -
-
+ ); case 'boolean': return ( -
- - - onUpdateVariable(variable.name, checked) - } - /> -
+ name={variable.name} + value={variable.value as boolean} + onChange={update} + /> ); case 'url': return ( -
- - - onUpdateVariable(variable.name, event.target.value) - } - placeholder='https://example.com' - className='h-7 font-mono text-xs' - /> -
+ ); case 'object': return ( -
+
) : {} } - onChange={(newValue) => - onUpdateVariable(variable.name, newValue) - } - label={variable.name} + onChange={update} + label={variableLabel(variable.name)} />
); case 'array': return ( -
+
- onUpdateVariable(variable.name, newValue) - } - label={variable.name} - placeholder='Add items...' + onChange={update} + label={variableLabel(variable.name)} />
); case 'image': return ( -
+
- onUpdateVariable(variable.name, newValue) - } - label={variable.name} + onChange={update} + label={variableLabel(variable.name)} multiple={variable.multiple} maxSize={variable.maxSize} maxFiles={variable.multiple ? 10 : 1} @@ -327,13 +351,11 @@ export const HTMLBlockJSVariablesControls: React.FC = ({ case 'file': return ( -
+
- onUpdateVariable(variable.name, newValue) - } - label={variable.name} + onChange={update} + label={variableLabel(variable.name)} accept={variable.accept} multiple={variable.multiple} maxSize={variable.maxSize} @@ -344,18 +366,12 @@ export const HTMLBlockJSVariablesControls: React.FC = ({ default: return ( -
- - - onUpdateVariable(variable.name, event.target.value) - } - className='h-7 font-mono text-xs' - /> -
+ ); } })} diff --git a/src/components/Blocks/TextBlock.tsx b/src/components/Blocks/TextBlock.tsx index 0faf090..b0b8099 100644 --- a/src/components/Blocks/TextBlock.tsx +++ b/src/components/Blocks/TextBlock.tsx @@ -34,6 +34,7 @@ import { SelectValue, } from '../ui/select'; import { cn } from '@/lib/utils'; +import { useUIStore } from '@/stores'; import { type TextAlign, type TextSizing, @@ -161,6 +162,10 @@ export const TextControl: React.FC = ({ id }) => { const weight = isBold ? 700 : fontWeight; + /* The hover and editing outlines are editor chrome: an export copies the + computed style of the paragraph, hover included, so drop them then. */ + const isExporting = useUIStore((state) => state.isExporting); + return ( <> = ({ id }) => { className={cn( // A block box, not a flex one, so `text-align` reaches the lines. // An outline, not a border: the hover must not resize the block. - 'my-auto block w-full flex-auto select-none overflow-hidden whitespace-pre-wrap hover:outline hover:outline-1 hover:outline-blue-500', + 'my-auto block w-full flex-auto select-none overflow-hidden whitespace-pre-wrap', + !isExporting && + 'hover:outline hover:outline-1 hover:outline-blue-500', sizing !== 'fixed' && 'break-words', // The app font only styles bold text that uses no font of its own. isBold && fontFamily === '' && 'poppins-font-family', isItalic && 'italic', isUnderline && 'underline', - editing && - 'cursor-text select-text outline outline-1 outline-blue-500', + editing && 'cursor-text select-text', + editing && !isExporting && 'outline outline-1 outline-blue-500', )} > {editing ? undefined : shownText} diff --git a/src/components/Brand/BrandKitEditor.tsx b/src/components/Brand/BrandKitEditor.tsx new file mode 100644 index 0000000..ca2c152 --- /dev/null +++ b/src/components/Brand/BrandKitEditor.tsx @@ -0,0 +1,465 @@ +import React, { useRef } from 'react'; +import { toast } from 'sonner'; +import { + Image as ImageIcon, + ImagePlus, + NotebookPen, + Palette, + Plus, + Tag, + Trash2, + Type, + type LucideIcon, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/components/lib/utils'; +import { ColorPicker } from '../CustomControls/ColorPicker'; +import { FontPicker } from '../CustomControls/FontPicker'; +import { CustomCollapse } from '../CustomControls/CustomCollapse'; +import { useBrandStore } from '@/stores/brand-store'; +import { + type BrandFontRole, + type BrandKit, + type BrandLogo, + type LogoVariant, + BRAND_FONT_ROLES, + BRAND_LIMITS, + LOGO_VARIANTS, + normalizeBrandKit, +} from '@/lib/brand/brand-kit'; +import { DEFAULT_FONT_FAMILY } from '@/lib/fonts/fonts'; + +const ROLE_LABELS: Record = { + heading: 'Headings', + body: 'Body', + code: 'Code', +}; + +const VARIANT_LABELS: Record = { + primary: 'Primary', + light: 'For dark backgrounds', + dark: 'For light backgrounds', + mark: 'Mark / icon', +}; + +export const BRAND_FILE_EXTENSION = '.kbrand'; + +/** Starting points for new colors, named by the role they usually play. */ +const COLOR_ROLES = ['Primary', 'Ink', 'Background', 'Accent']; +const NEW_COLORS = ['#6366f1', '#111318', '#f7f7f8', '#f43f5e', '#10b981']; + +const readAsDataUrl = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); + +const naturalSize = (src: string) => + new Promise<{ width: number; height: number }>((resolve, reject) => { + const image = new Image(); + image.onload = () => + resolve({ + width: image.naturalWidth || 512, + height: image.naturalHeight || 512, + }); + image.onerror = () => reject(new Error('Not an image.')); + image.src = src; + }); + +/** Download the kit as a `.kbrand` file. */ +export const exportBrandKitFile = (kit: BrandKit): void => { + const blob = new Blob([JSON.stringify(kit, null, 2)], { + type: 'application/json', + }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${kit.name.trim() || 'brand'}${BRAND_FILE_EXTENSION}`; + link.click(); + setTimeout(() => URL.revokeObjectURL(link.href), 1000); +}; + +/** Replace the kit with the one in a `.kbrand` file. */ +export const importBrandKitFile = async ( + file: File | undefined, +): Promise => { + if (!file) return; + try { + const next = normalizeBrandKit(JSON.parse(await file.text())); + useBrandStore.getState().setKit(next); + toast.success(`Brand kit ${next.name || file.name} imported`); + } catch { + toast.error('That file is not a brand kit'); + } +}; + +/** The panel shows the kit in collapsible sections, the dialog in one page. */ +const CompactContext = React.createContext(false); + +const Section: React.FC<{ + title: string; + icon: LucideIcon; + /** Shown next to the title of a collapsed section. */ + count?: number; + /** Open when the panel shows the kit. */ + open?: boolean; + hint?: string; + action?: React.ReactNode; + children: React.ReactNode; +}> = ({ title, icon: Icon, count, open = false, hint, action, children }) => { + const compact = React.useContext(CompactContext); + + if (compact) { + return ( + + + + {count !== undefined && count > 0 && ( + {count} + )} +
+ } + > + {hint && ( +

+ {hint} +

+ )} + {children} + {action &&
{action}
} + + ); + } + + return ( +
+
+
+ + {hint && ( + {hint} + )} +
+ {action} +
+ {children} +
+ ); +}; + +/** + * Edit the brand kit: colors, fonts, logos and guidelines for Agent. The + * brand dialog shows it in columns; the properties panel, `compact`, in one. + */ +export const BrandKitEditor: React.FC<{ compact?: boolean }> = ({ + compact = false, +}) => { + const kit = useBrandStore((state) => state.kit); + const updateKit = useBrandStore((state) => state.updateKit); + const logoInput = useRef(null); + + const addLogos = async (files: FileList | null) => { + if (!files) return; + const added: BrandLogo[] = []; + for (const file of Array.from(files)) { + if (kit.logos.length + added.length >= BRAND_LIMITS.logos) { + toast.error(`A brand kit holds up to ${BRAND_LIMITS.logos} logos`); + break; + } + if (file.size > BRAND_LIMITS.logoBytes) { + toast.error(`${file.name} is larger than 2 MB`); + continue; + } + try { + const src = await readAsDataUrl(file); + const size = await naturalSize(src); + added.push({ + id: `logo-${Date.now().toString(36)}-${added.length}`, + name: file.name.replace(/\.[^.]+$/, ''), + variant: kit.logos.length + added.length === 0 ? 'primary' : 'mark', + src, + ...size, + }); + } catch { + toast.error(`Could not read ${file.name}`); + } + } + if (added.length) updateKit({ logos: [...kit.logos, ...added] }); + }; + + const updateLogo = (id: string, patch: Partial) => + updateKit({ + logos: kit.logos.map((logo) => + logo.id === id ? { ...logo, ...patch } : logo, + ), + }); + + return ( + +
+
+ updateKit({ name: event.target.value })} + /> +
+ +
= BRAND_LIMITS.colors} + onClick={() => + updateKit({ + colors: [ + ...kit.colors, + { + name: COLOR_ROLES[kit.colors.length] ?? '', + value: NEW_COLORS[kit.colors.length % NEW_COLORS.length], + }, + ], + }) + } + > + + Add color + + } + > + {kit.colors.length === 0 ? ( +

No colors yet.

+ ) : ( +
+ {kit.colors.map((color, index) => ( +
+
+ + updateKit({ + colors: kit.colors.map((item, position) => + position === index ? { ...item, value } : item, + ), + }) + } + /> +
+ + updateKit({ + colors: kit.colors.map((item, position) => + position === index + ? { ...item, name: event.target.value } + : item, + ), + }) + } + /> + +
+ ))} +
+ )} +
+ +
+
+ {BRAND_FONT_ROLES.map((role) => ( +
+ + {ROLE_LABELS[role]} + + { + const fonts = { ...kit.fonts }; + if (source === 'default' || family === '') { + delete fonts[role]; + } else { + fonts[role] = { family, source }; + } + updateKit({ fonts }); + }} + /> +
+ ))} +
+
+ +
0} + hint='PNG, SVG, JPEG or WebP, up to 2 MB. Mark which one goes on dark and on light backgrounds.' + action={ + + } + > + { + void addLogos(event.target.files); + event.target.value = ''; + }} + /> + {kit.logos.length === 0 ? ( +

No logos yet.

+ ) : ( +
+ {kit.logos.map((logo) => ( +
+
+ {logo.name} +
+
+
+ + updateLogo(logo.id, { name: event.target.value }) + } + /> + +
+ +
+
+ ))} +
+ )} +
+ +
+