pagiera 0.2.0-alpha.44 → 0.2.0-alpha.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/data.js.map +1 -1
  2. package/dist/full-editor.js +75 -10
  3. package/dist/full-editor.js.map +2 -2
  4. package/dist/internal/app/api/ai-design/route.d.ts +5 -0
  5. package/dist/internal/app/api/ai-design/route.d.ts.map +1 -1
  6. package/dist/internal/app/api/ai-design/route.js +1 -1
  7. package/dist/internal/app/api/ai-design/route.js.map +1 -1
  8. package/dist/internal/app/p/editor/editor.d.ts.map +1 -1
  9. package/dist/internal/app/p/editor/editor.js +23 -7
  10. package/dist/internal/app/p/editor/editor.js.map +1 -1
  11. package/dist/internal/app/p/editor/fields.d.ts +1 -1
  12. package/dist/internal/app/p/editor/fields.d.ts.map +1 -1
  13. package/dist/internal/app/p/editor/fields.js +5 -1
  14. package/dist/internal/app/p/editor/fields.js.map +1 -1
  15. package/dist/internal/app/p/editor/inspector.d.ts.map +1 -1
  16. package/dist/internal/app/p/editor/inspector.js +9 -2
  17. package/dist/internal/app/p/editor/inspector.js.map +1 -1
  18. package/dist/internal/lib/editor/style.d.ts.map +1 -1
  19. package/dist/internal/lib/editor/style.js +8 -0
  20. package/dist/internal/lib/editor/style.js.map +1 -1
  21. package/dist/internal/lib/editor/types.d.ts +3 -1
  22. package/dist/internal/lib/editor/types.d.ts.map +1 -1
  23. package/dist/internal/lib/editor/types.js.map +1 -1
  24. package/dist/internal/lib/editor/validate.d.ts.map +1 -1
  25. package/dist/internal/lib/editor/validate.js +2 -1
  26. package/dist/internal/lib/editor/validate.js.map +1 -1
  27. package/dist/internal/lib/pages.d.ts +1 -0
  28. package/dist/internal/lib/pages.d.ts.map +1 -1
  29. package/dist/internal/lib/render/css.d.ts.map +1 -1
  30. package/dist/internal/lib/render/css.js +10 -2
  31. package/dist/internal/lib/render/css.js.map +1 -1
  32. package/dist/runtime.js +13 -2
  33. package/dist/runtime.js.map +2 -2
  34. package/dist/server.js +3 -2
  35. package/dist/server.js.map +2 -2
  36. package/package.json +1 -1
package/dist/data.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/internal/lib/editor/types.ts", "../src/internal/lib/data/source.ts", "../src/internal/lib/render/load-data.ts"],
4
- "sourcesContent": ["export const ELEMENT_TYPES = [\n \"Frame\",\n \"Stack\",\n \"Section\",\n \"Container\",\n \"Grid\",\n \"Heading\",\n \"Text\",\n \"Image\",\n \"Button\",\n \"Video\",\n \"Icon\",\n \"Form\",\n \"Input\",\n \"Textarea\",\n \"Request\",\n \"Repeat\",\n] as const;\n\nexport type ElementType = (typeof ELEMENT_TYPES)[number];\n\n/** One key/value pair on a request; values may carry `{{\u2026}}` tokens. */\nexport type RequestPair = { key: string; value: string };\n\nexport const HTTP_METHODS = [\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"] as const;\nexport type HttpMethod = (typeof HTTP_METHODS)[number];\n\nexport const DATA_SOURCE_NOT_FOUND_BEHAVIORS = [\"empty\", \"page-404\"] as const;\nexport type DataSourceNotFoundBehavior = (typeof DATA_SOURCE_NOT_FOUND_BEHAVIORS)[number];\n\n/** Methods that carry a request body. */\nexport function sendsBody(method: HttpMethod) {\n return method === \"POST\" || method === \"PUT\" || method === \"PATCH\";\n}\n\n/**\n * A JSON endpoint the page pulls content from. Sources live on the page so a\n * Repeat block can name one without carrying the URL on every element.\n */\nexport type DataSource = {\n id: string;\n name: string;\n url: string;\n /** Dotted path to the array inside the payload; \"\" when it is the root. */\n path: string;\n method?: HttpMethod;\n /** JSON body for POST/PUT/PATCH; tokens are resolved before sending. */\n body?: string;\n /** Appended to the URL as a query string. */\n params?: RequestPair[];\n /** Sent as request headers \u2014 for API keys and the like. */\n headers?: RequestPair[];\n /** Controls whether an upstream 404 empties this source or rejects the whole page. */\n onNotFound?: DataSourceNotFoundBehavior;\n};\n\n/**\n * Values a request token can read. Everything else resolves to an empty\n * string, so a typo cannot leak an unrelated value into the URL.\n */\nexport type RequestContext = {\n /** The visitor's query string, e.g. `{{query.id}}` on /post?id=5. */\n query: Record<string, string>;\n /** Values captured by a dynamic page path, e.g. `{{params.slug}}`. */\n params: Record<string, string>;\n /** The page being rendered, e.g. `{{page.slug}}`. */\n page: { slug: string };\n};\n\nexport type ResizeHandle = \"nw\" | \"ne\" | \"sw\" | \"se\" | \"n\" | \"s\" | \"w\" | \"e\";\n\n/* ------------------------------------------------------------- breakpoints */\n\nexport const BREAKPOINTS = [\"desktop\", \"tablet\", \"mobile\"] as const;\nexport type Breakpoint = string;\n\nexport type BreakpointDefinition = {\n id: string;\n name: string;\n width: number;\n};\n\nexport const DEFAULT_BREAKPOINTS: BreakpointDefinition[] = [\n { id: \"desktop\", name: \"Desktop\", width: 1280 },\n { id: \"tablet\", name: \"Tablet\", width: 768 },\n { id: \"mobile\", name: \"Mobile\", width: 375 },\n];\n\nexport const BREAKPOINT_WIDTHS: Record<string, number> = {\n desktop: 1280,\n tablet: 768,\n mobile: 375,\n};\n\n/**\n * Styles cascade from the widest breakpoint down, so a value set on desktop\n * holds everywhere until a narrower breakpoint overrides it.\n */\nexport const BREAKPOINT_CHAIN: Record<string, Breakpoint[]> = {\n desktop: [\"desktop\"],\n tablet: [\"desktop\", \"tablet\"],\n mobile: [\"desktop\", \"tablet\", \"mobile\"],\n};\n\n/* ------------------------------------------------------------------ styles */\n\n/** `fill` stretches to the parent, `auto` shrinks to the content. */\nexport type SizeMode = \"fixed\" | \"fill\" | \"auto\";\nexport type Constraint = \"start\" | \"center\" | \"end\" | \"stretch\";\n/** `absolute` positions children by x/y; `stack` lays them out with flexbox. */\nexport type LayoutMode = \"absolute\" | \"stack\";\nexport type Direction = \"row\" | \"column\";\nexport type Justify = \"start\" | \"center\" | \"end\" | \"between\";\nexport type Align = \"start\" | \"center\" | \"end\" | \"stretch\";\nexport type TextAlign = \"left\" | \"center\" | \"right\" | \"justify\";\nexport type TextTransform = \"none\" | \"uppercase\" | \"lowercase\" | \"capitalize\";\nexport type ObjectFit = \"cover\" | \"contain\" | \"fill\" | \"none\";\nexport type Overflow = \"visible\" | \"hidden\" | \"auto\" | \"scroll\";\nexport type Entrance = \"none\" | \"fade\" | \"up\" | \"down\" | \"left\" | \"right\" | \"zoom\";\nexport type MotionCurve = \"ease\" | \"spring\";\nexport type CursorStyle = \"auto\" | \"default\" | \"pointer\" | \"text\" | \"grab\" | \"zoom-in\" | \"none\";\n/**\n * How an element sits relative to its siblings.\n *\n * `absolute` is per element, not per container: it lifts this one out of the\n * flow and places it at x/y while everything around it keeps stacking. Making\n * the whole parent free instead would move every sibling to satisfy one of\n * them.\n */\nexport type PositionMode = \"static\" | \"sticky\" | \"fixed\" | \"absolute\";\n/** Which edge a pinned element holds to. */\nexport type PinSide = \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type BgSize = \"cover\" | \"contain\" | \"auto\";\nexport type BlendMode =\n | \"normal\"\n | \"multiply\"\n | \"screen\"\n | \"overlay\"\n | \"darken\"\n | \"lighten\"\n | \"difference\"\n | \"luminosity\";\nexport type BorderStyle = \"solid\" | \"dashed\" | \"dotted\";\n\nexport type ElementStyle = {\n // Box \u2014 x/y only apply inside an `absolute` parent.\n x: number;\n y: number;\n constraintX: Constraint;\n constraintY: Constraint;\n w: number;\n h: number;\n widthMode: SizeMode;\n heightMode: SizeMode;\n\n // How this element arranges its own children.\n layout: LayoutMode;\n direction: Direction;\n gap: number;\n padT: number;\n padR: number;\n padB: number;\n padL: number;\n /**\n * Space held below the element, outside its own box.\n *\n * Separate from `padB` on purpose: padding is the breathing room the\n * author gave the content inside a section, while this is the distance to\n * whatever comes next. Sharing one value would make adjusting the rhythm\n * between sections quietly reflow their insides.\n */\n marginB: number;\n justify: Justify;\n align: Align;\n wrap: boolean;\n /** Grid columns; only read when `layout` is `stack` on a Grid element. */\n columns: number;\n\n // Appearance\n bg: string;\n /** A full CSS gradient value, or \"\" for none. Painted over `bg`. */\n gradient: string;\n color: string;\n radius: number;\n opacity: number;\n borderW: number;\n /** Per-edge override; null inherits borderW. */\n borderT: number | null;\n borderR: number | null;\n borderB: number | null;\n borderL: number | null;\n borderC: string;\n borderStyle: BorderStyle;\n /** A full CSS box-shadow value, or \"\" for none. */\n shadow: string;\n rotate: number;\n\n // Typography\n fontFamily: string;\n fontSize: number;\n fontWeight: string;\n lineHeight: number;\n letterSpacing: number;\n textAlign: TextAlign;\n textTransform: TextTransform;\n\n // Composition \u2014 the pieces that make a layout feel designed rather than\n // stacked: clipping, sticky rails, imagery, glass and blend effects.\n overflow: Overflow;\n position: PositionMode;\n /** CSS stacking order, independent from the internal document order. */\n zIndex: number;\n /** Distance from `pinSide` while pinned; read when position is sticky or fixed. */\n stickyOffset: number;\n /** Edge a sticky or fixed element pins to. */\n pinSide: PinSide;\n /** Background image URL, or \"\" for none. Painted over `gradient`. */\n bgImage: string;\n bgSize: BgSize;\n bgPosition: string;\n /** Blurs the element's own content, in px. */\n blur: number;\n /** Blurs whatever sits behind the element, in px \u2014 the glass effect. */\n backdropBlur: number;\n blendMode: BlendMode;\n /** Percent; 100 leaves the element alone. */\n scale: number;\n /** A CSS ratio such as \"16/9\", or \"\" to leave height to the layout. */\n aspectRatio: string;\n\n /** Entrance effect, played once when the element scrolls into view. */\n entrance: Entrance;\n /** Milliseconds. */\n entranceDuration: number;\n entranceDelay: number;\n entranceCurve: MotionCurve;\n entranceBezier: string;\n springStiffness: number;\n springDamping: number;\n cursor: CursorStyle;\n\n /** Hidden at this breakpoint. */\n hidden: boolean;\n};\n\nexport const STYLE_KEYS = [\n \"x\",\n \"y\",\n \"constraintX\",\n \"constraintY\",\n \"w\",\n \"h\",\n \"widthMode\",\n \"heightMode\",\n \"layout\",\n \"direction\",\n \"gap\",\n \"padT\",\n \"padR\",\n \"padB\",\n \"padL\",\n \"marginB\",\n \"justify\",\n \"align\",\n \"wrap\",\n \"columns\",\n \"bg\",\n \"gradient\",\n \"color\",\n \"radius\",\n \"opacity\",\n \"borderW\",\n \"borderT\",\n \"borderR\",\n \"borderB\",\n \"borderL\",\n \"borderC\",\n \"borderStyle\",\n \"shadow\",\n \"rotate\",\n \"fontFamily\",\n \"fontSize\",\n \"fontWeight\",\n \"lineHeight\",\n \"letterSpacing\",\n \"textAlign\",\n \"textTransform\",\n \"overflow\",\n \"position\",\n \"zIndex\",\n \"stickyOffset\",\n \"pinSide\",\n \"bgImage\",\n \"bgSize\",\n \"bgPosition\",\n \"blur\",\n \"backdropBlur\",\n \"blendMode\",\n \"scale\",\n \"aspectRatio\",\n \"entrance\",\n \"entranceDuration\",\n \"entranceDelay\",\n \"entranceCurve\",\n \"entranceBezier\",\n \"springStiffness\",\n \"springDamping\",\n \"cursor\",\n \"hidden\",\n] as const satisfies ReadonlyArray<keyof ElementStyle>;\n\nexport type StyleKey = (typeof STYLE_KEYS)[number];\n\nexport type CanvasElement = {\n id: string;\n type: ElementType;\n name?: string;\n parentId?: string;\n z: number;\n locked?: boolean;\n /** Page-local reusable component metadata. */\n componentRole?: \"master\" | \"instance\";\n componentId?: string;\n componentSourceId?: string;\n variant?: string;\n styleBindings?: Partial<Record<StyleKey, string>>;\n\n // Content is shared across breakpoints.\n content?: string;\n /** Sandboxed HTML/CSS used by code components. */\n code?: string;\n src?: string;\n alt?: string;\n objectFit?: ObjectFit;\n iconName?: PagieraIconName;\n placeholder?: string;\n fieldName?: string;\n inputType?: \"text\" | \"email\" | \"password\" | \"number\" | \"tel\" | \"url\" | \"search\";\n required?: boolean;\n formAction?: string;\n formMethod?: HttpMethod;\n formSubmitMode?: \"request\" | \"native\";\n formContentType?: \"json\" | \"form-data\" | \"urlencoded\";\n /** Optional request body. `{{form.email}}` tokens read submitted fields. */\n formBody?: string;\n /** One `Header: value` pair per line. */\n formHeaders?: string;\n formSuccessMessage?: string;\n formErrorMessage?: string;\n formResetOnSuccess?: boolean;\n buttonType?: \"button\" | \"submit\" | \"reset\";\n href?: string;\n target?: \"_self\" | \"_blank\";\n interaction?: {\n trigger: \"click\";\n action: \"navigate\" | \"scroll-to\" | \"toggle-layer\" | \"show-layer\" | \"hide-layer\";\n value: string;\n target?: \"_self\" | \"_blank\";\n };\n\n /** Data source read by a Request/Repeat block or a directly-bound element. */\n sourceId?: string;\n /**\n * Inside Request/Repeat, pulls this field off the current object instead\n * of using the element's content. Dotted paths work: \"author.name\".\n */\n binding?: string;\n\n /** Desktop values; every breakpoint falls back to these. */\n base: ElementStyle;\n /** Narrower-breakpoint deltas, applied over `base` in cascade order. */\n overrides?: Record<string, Partial<ElementStyle>>;\n /** Applied on pointer hover, on top of the resolved breakpoint style. */\n hover?: Partial<ElementStyle>;\n /** Applied while the pointer is pressed. */\n press?: Partial<ElementStyle>;\n loop?: { type: \"pulse\" | \"float\" | \"spin\"; duration: number };\n draggable?: boolean;\n};\n\n/* ---------------------------------------------------------------- defaults */\n\nexport const BASE_STYLE: ElementStyle = {\n x: 0,\n y: 0,\n constraintX: \"start\",\n constraintY: \"start\",\n w: 200,\n h: 100,\n widthMode: \"fixed\",\n heightMode: \"fixed\",\n\n layout: \"absolute\",\n direction: \"column\",\n gap: 0,\n padT: 0,\n padR: 0,\n padB: 0,\n padL: 0,\n marginB: 0,\n justify: \"start\",\n align: \"start\",\n wrap: false,\n columns: 3,\n\n bg: \"transparent\",\n gradient: \"\",\n color: \"#27272a\",\n radius: 0,\n opacity: 100,\n borderW: 0,\n borderT: null,\n borderR: null,\n borderB: null,\n borderL: null,\n borderC: \"transparent\",\n borderStyle: \"solid\",\n shadow: \"\",\n rotate: 0,\n\n fontFamily: \"inherit\",\n fontSize: 16,\n fontWeight: \"normal\",\n lineHeight: 1.5,\n letterSpacing: 0,\n textAlign: \"left\",\n textTransform: \"none\",\n\n overflow: \"visible\",\n position: \"static\",\n zIndex: 0,\n stickyOffset: 0,\n pinSide: \"top\",\n bgImage: \"\",\n bgSize: \"cover\",\n bgPosition: \"center\",\n blur: 0,\n backdropBlur: 0,\n blendMode: \"normal\",\n scale: 100,\n aspectRatio: \"\",\n\n entrance: \"none\",\n entranceDuration: 600,\n entranceDelay: 0,\n entranceCurve: \"ease\",\n entranceBezier: \"0.44, 0, 0.56, 1\",\n springStiffness: 300,\n springDamping: 30,\n cursor: \"auto\",\n\n hidden: false,\n};\n\nexport function makeStyle(overrides: Partial<ElementStyle>): ElementStyle {\n return { ...BASE_STYLE, ...overrides };\n}\n\n/** Every field a freshly dropped element starts with. */\nexport const ELEMENT_DEFAULTS: Record<\n ElementType,\n { style: Partial<ElementStyle>; props?: Partial<CanvasElement> }\n> = {\n Frame: {\n style: {\n w: 640,\n h: 420,\n widthMode: \"fixed\",\n heightMode: \"fixed\",\n layout: \"absolute\",\n direction: \"column\",\n overflow: \"hidden\",\n bg: \"#ffffff\",\n borderW: 1,\n borderC: \"#e4e4e7\",\n radius: 12,\n },\n },\n Stack: {\n style: {\n w: 600,\n h: 160,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 16,\n padT: 0,\n padR: 0,\n padB: 0,\n padL: 0,\n justify: \"start\",\n align: \"stretch\",\n bg: \"transparent\",\n },\n },\n Section: {\n style: {\n w: 1280,\n h: 480,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 24,\n padT: 64,\n padR: 48,\n padB: 64,\n padL: 48,\n justify: \"start\",\n align: \"stretch\",\n bg: \"#ffffff\",\n },\n },\n Container: {\n style: {\n w: 600,\n h: 240,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 16,\n padT: 24,\n padR: 24,\n padB: 24,\n padL: 24,\n align: \"stretch\",\n bg: \"transparent\",\n borderW: 1,\n borderC: \"#e4e4e7\",\n radius: 12,\n },\n },\n Grid: {\n style: {\n w: 900,\n h: 300,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"row\",\n columns: 3,\n gap: 24,\n align: \"stretch\",\n },\n },\n Heading: {\n style: {\n w: 480,\n h: 48,\n widthMode: \"fill\",\n heightMode: \"auto\",\n fontSize: 40,\n fontWeight: \"bold\",\n lineHeight: 1.2,\n letterSpacing: -0.5,\n color: \"#18181b\",\n },\n props: { content: \"Heading\" },\n },\n Text: {\n style: {\n w: 480,\n h: 24,\n widthMode: \"fill\",\n heightMode: \"auto\",\n fontSize: 16,\n lineHeight: 1.6,\n color: \"#52525b\",\n },\n props: { content: \"Text block content\" },\n },\n Image: {\n style: { w: 400, h: 260, widthMode: \"fill\", bg: \"#e4e4e7\", radius: 12 },\n props: { src: \"\", alt: \"\", objectFit: \"cover\" },\n },\n Button: {\n style: {\n w: 140,\n h: 44,\n widthMode: \"auto\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"row\",\n justify: \"center\",\n align: \"center\",\n padT: 12,\n padR: 22,\n padB: 12,\n padL: 22,\n bg: \"#2563eb\",\n color: \"#ffffff\",\n radius: 8,\n fontSize: 15,\n fontWeight: \"500\",\n textAlign: \"center\",\n },\n props: { content: \"Button\", href: \"\", target: \"_self\", buttonType: \"button\" },\n },\n Video: {\n style: { w: 640, h: 360, widthMode: \"fill\", bg: \"#18181b\", radius: 12 },\n props: { src: \"\" },\n },\n Icon: {\n style: { w: 24, h: 24, widthMode: \"fixed\", heightMode: \"fixed\", color: \"#5402E6\" },\n props: { iconName: \"star\" },\n },\n Form: {\n style: {\n w: 560,\n h: 240,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 14,\n align: \"stretch\",\n },\n props: {\n formAction: \"\",\n formMethod: \"POST\",\n formSubmitMode: \"request\",\n formContentType: \"json\",\n formSuccessMessage: \"Sent successfully.\",\n formErrorMessage: \"Something went wrong.\",\n },\n },\n Input: {\n style: {\n w: 320,\n h: 46,\n widthMode: \"fill\",\n heightMode: \"fixed\",\n padT: 0,\n padR: 14,\n padB: 0,\n padL: 14,\n bg: \"#ffffff\",\n color: \"#18181b\",\n borderW: 1,\n borderC: \"#d4d4d8\",\n radius: 10,\n fontSize: 15,\n },\n props: { placeholder: \"Enter a value\u2026\", fieldName: \"field\", inputType: \"text\" },\n },\n Textarea: {\n style: {\n w: 320,\n h: 120,\n widthMode: \"fill\",\n heightMode: \"fixed\",\n padT: 12,\n padR: 14,\n padB: 12,\n padL: 14,\n bg: \"#ffffff\",\n color: \"#18181b\",\n borderW: 1,\n borderC: \"#d4d4d8\",\n radius: 10,\n fontSize: 15,\n },\n props: { placeholder: \"Write your message\u2026\", fieldName: \"message\" },\n },\n Request: {\n style: {\n w: 900,\n h: 200,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 16,\n align: \"stretch\",\n },\n props: { sourceId: \"\" },\n },\n Repeat: {\n style: {\n w: 900,\n h: 200,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"row\",\n gap: 24,\n align: \"stretch\",\n wrap: true,\n },\n props: { sourceId: \"\" },\n },\n};\n\n/* ----------------------------------------------------------------- helpers */\n\nconst CONTAINER_TYPES: ReadonlySet<ElementType> = new Set([\n \"Frame\",\n \"Stack\",\n \"Section\",\n \"Container\",\n \"Grid\",\n \"Button\",\n \"Form\",\n \"Request\",\n \"Repeat\",\n]);\n\n/** Whether an element can hold children. */\nexport function isContainer(type: ElementType) {\n return CONTAINER_TYPES.has(type);\n}\n\nconst TEXTUAL_TYPES: ReadonlySet<ElementType> = new Set([\n \"Heading\",\n \"Text\",\n \"Button\",\n]);\n\n/** Whether an element renders editable text. */\nexport function isTextual(type: ElementType) {\n return TEXTUAL_TYPES.has(type);\n}\n\n/** Page-level settings; the canvas behaves as the root container. */\nexport type RootStyle = {\n documentMode: \"page\" | \"component\";\n /** Content is centred inside this width unless `fullWidth` is set. */\n maxWidth: number;\n /** Editable design-surface height; the public page can still grow with content. */\n canvasHeight: number;\n /** Let content run edge to edge instead of being capped at `maxWidth`. */\n fullWidth: boolean;\n bg: string;\n layout: LayoutMode;\n direction: Direction;\n gap: number;\n padT: number;\n padR: number;\n padB: number;\n padL: number;\n align: Align;\n fontFamily: string;\n /** Cross-document animation used by links on the published site. */\n pageTransition: \"smooth\" | \"fade\" | \"slide\" | \"none\";\n /** Duration of the incoming published-page animation in milliseconds. */\n pageTransitionDuration: number;\n /** User-defined viewport previews, ordered from widest to narrowest. */\n breakpoints?: BreakpointDefinition[];\n /** The breakpoint whose values live in every element's `base` style. */\n baseBreakpointId?: string;\n variables?: DesignVariable[];\n customFonts?: CustomFont[];\n /**\n * Raw CSS appended after the generated sheet, so it can override any rule\n * the builder produced. Escape hatch for what the inspector cannot express.\n */\n customCss?: string;\n /**\n * Raw JavaScript run on the published page, after the document parses.\n *\n * It is deliberately never executed inside the editor: the canvas and the\n * template preview both render without scripting, so a page cannot reach\n * the editor it is being built in.\n */\n customJs?: string;\n};\n\nexport type CustomFont = { id: string; name: string; url: string; weight: number; style: \"normal\" | \"italic\" };\n\nexport type DesignVariable = {\n id: string;\n name: string;\n type: \"color\" | \"number\";\n value: string | number;\n};\n\nexport const DEFAULT_ROOT_STYLE: RootStyle = {\n documentMode: \"page\",\n maxWidth: 1280,\n canvasHeight: 800,\n fullWidth: false,\n bg: \"#ffffff\",\n layout: \"stack\",\n direction: \"column\",\n gap: 0,\n padT: 0,\n padR: 0,\n padB: 0,\n padL: 0,\n align: \"stretch\",\n fontFamily: \"inherit\",\n pageTransition: \"smooth\",\n pageTransitionDuration: 380,\n};\n\nexport const FONT_STACKS: Array<{ label: string; value: string }> = [\n { label: \"Inherit\", value: \"inherit\" },\n { label: \"Sans\", value: \"ui-sans-serif, system-ui, sans-serif\" },\n { label: \"Serif\", value: \"ui-serif, Georgia, serif\" },\n { label: \"Mono\", value: \"ui-monospace, SFMono-Regular, monospace\" },\n];\n\nexport const SHADOW_PRESETS: Array<{ label: string; value: string }> = [\n { label: \"None\", value: \"\" },\n { label: \"Small\", value: \"0 1px 2px rgba(0,0,0,0.06)\" },\n { label: \"Medium\", value: \"0 4px 12px rgba(0,0,0,0.08)\" },\n { label: \"Large\", value: \"0 12px 32px rgba(0,0,0,0.12)\" },\n { label: \"Glow\", value: \"0 0 0 4px rgba(37,99,235,0.15)\" },\n];\n\nexport const ASPECT_RATIOS: Array<{ label: string; value: string }> = [\n { label: \"Free\", value: \"\" },\n { label: \"Square 1:1\", value: \"1/1\" },\n { label: \"Photo 4:3\", value: \"4/3\" },\n { label: \"Wide 16:9\", value: \"16/9\" },\n { label: \"Ultra 21:9\", value: \"21/9\" },\n { label: \"Portrait 3:4\", value: \"3/4\" },\n];\n\nexport const ENTRANCES: Array<{ label: string; value: Entrance }> = [\n { label: \"None\", value: \"none\" },\n { label: \"Fade in\", value: \"fade\" },\n { label: \"Rise up\", value: \"up\" },\n { label: \"Drop down\", value: \"down\" },\n { label: \"Slide from left\", value: \"left\" },\n { label: \"Slide from right\", value: \"right\" },\n { label: \"Zoom in\", value: \"zoom\" },\n];\n\nexport const DRAG_MIME = \"application/pagiera-element-type\";\n/** Set when dragging an element that already exists on the canvas. */\nexport const MOVE_MIME = \"application/pagiera-element-id\";\nimport type { PagieraIconName } from \"../../../icon-names\";\n", "import {\n type DataSource,\n type HttpMethod,\n type RequestContext,\n type RequestPair,\n sendsBody,\n} from \"@/lib/editor/types\";\n\n/**\n * Fetching an author-supplied URL from our server is a server-side request\n * forgery vector: without checks it can reach cloud metadata endpoints, admin\n * services on localhost, or anything else inside the network perimeter. Every\n * hop is validated against this list, and redirects are followed by hand so a\n * public URL cannot bounce into private space.\n */\nconst BLOCKED_HOSTS = new Set([\n \"localhost\",\n \"127.0.0.1\",\n \"0.0.0.0\",\n \"::1\",\n \"[::1]\",\n \"metadata.google.internal\",\n \"metadata.goog\",\n]);\n\nconst PRIVATE_IPV4 =\n /^(10\\.|127\\.|0\\.|169\\.254\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[01])\\.)/;\n\n/** Hostnames that only resolve inside a private network. */\nconst PRIVATE_SUFFIXES = [\".local\", \".internal\", \".localdomain\", \".home.arpa\"];\n\nexport const MAX_ROWS = 200;\nconst MAX_BYTES = 2_000_000;\nconst TIMEOUT_MS = 8000;\nconst MAX_REDIRECTS = 3;\n\nexport class DataSourceError extends Error {\n constructor(message: string, public readonly status?: number) {\n super(message);\n this.name = \"DataSourceError\";\n }\n}\n\nexport function assertFetchableUrl(raw: string): URL {\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n throw new DataSourceError(\"That is not a valid URL.\");\n }\n\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n throw new DataSourceError(\"Only http and https URLs can be requested.\");\n }\n\n const host = url.hostname.toLowerCase();\n if (BLOCKED_HOSTS.has(host) || PRIVATE_IPV4.test(host)) {\n throw new DataSourceError(`Requests to ${host} are not allowed.`);\n }\n if (PRIVATE_SUFFIXES.some((suffix) => host.endsWith(suffix))) {\n throw new DataSourceError(`Requests to ${host} are not allowed.`);\n }\n // Bracketed IPv6 literals: anything link-local or unique-local.\n if (host.startsWith(\"[\") && /^\\[(fe80|fc|fd)/i.test(host)) {\n throw new DataSourceError(\"Requests to private addresses are not allowed.\");\n }\n\n return url;\n}\n\n/**\n * Walks a dotted path such as `data.items` or `results.0.tags`. An empty path\n * returns the payload itself, which is what a bare JSON array needs.\n */\nexport function selectPath(payload: unknown, path: string): unknown {\n const steps = path.split(\".\").map((s) => s.trim()).filter(Boolean);\n let cursor: unknown = payload;\n for (const step of steps) {\n if (cursor === null || typeof cursor !== \"object\") return undefined;\n cursor = (cursor as Record<string, unknown>)[step];\n }\n return cursor;\n}\n\n/** Reads a body with a hard cap so a huge response cannot exhaust memory. */\nasync function readCapped(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n\n const chunks: Uint8Array[] = [];\n let total = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > MAX_BYTES) {\n await reader.cancel();\n throw new DataSourceError(\"That response is too large (over 2 MB).\");\n }\n chunks.push(value);\n }\n return new TextDecoder().decode(\n chunks.reduce<Uint8Array>((acc, chunk) => {\n const merged = new Uint8Array(acc.length + chunk.length);\n merged.set(acc);\n merged.set(chunk, acc.length);\n return merged;\n }, new Uint8Array()),\n );\n}\n\nexport type SourceResult = { rows: Array<Record<string, unknown>>; keys: string[] };\n\n/**\n * Requests a source and normalizes it to rows. A root object becomes one row\n * for Request; an array stays multiple rows for Repeat.\n */\nexport type LoadOptions = {\n /**\n * Seconds to cache the response for. `false` bypasses the cache \u2014 only for\n * the editor's Test button; on a rendered route it would force an\n * otherwise-static page to become dynamic on every request.\n */\n revalidate?: number | false;\n /** Supplies `{{query.\u2026}}` / `{{page.\u2026}}` values for this request. */\n context?: RequestContext;\n};\n\nexport const DEFAULT_REVALIDATE = 60;\n\nexport async function loadSource(\n source: DataSource,\n { revalidate = DEFAULT_REVALIDATE, context = EMPTY_CONTEXT }: LoadOptions = {},\n): Promise<SourceResult> {\n const request = buildRequest(source, context);\n let target = request.url;\n\n let response: Response | undefined;\n for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {\n response = await fetch(target, {\n // Redirects are followed by hand so each hop is validated too.\n redirect: \"manual\",\n method: request.method,\n headers: request.headers,\n body: request.body,\n signal: AbortSignal.timeout(TIMEOUT_MS),\n // Only GET responses are cacheable; anything with side effects is\n // fetched fresh every time.\n ...(revalidate === false || request.method !== \"GET\"\n ? { cache: \"no-store\" as const }\n : { next: { revalidate } }),\n });\n\n if (response.status < 300 || response.status >= 400) break;\n\n const location = response.headers.get(\"location\");\n if (!location) break;\n target = assertFetchableUrl(new URL(location, target).toString());\n response = undefined;\n }\n\n if (!response) throw new DataSourceError(\"Too many redirects.\");\n if (!response.ok) {\n throw new DataSourceError(`The API replied ${response.status}.`, response.status);\n }\n\n const text = await readCapped(response);\n let payload: unknown;\n try {\n payload = JSON.parse(text);\n } catch {\n throw new DataSourceError(\"The response was not JSON.\");\n }\n\n const selected = selectPath(payload, source.path);\n const selectedRows = Array.isArray(selected)\n ? selected\n : selected !== null && typeof selected === \"object\"\n ? [selected]\n : undefined;\n if (!selectedRows) {\n throw new DataSourceError(\n source.path\n ? `\"${source.path}\" is not a list in that response.`\n : \"That response is neither an object nor a list.\",\n );\n }\n\n const rows = selectedRows\n .slice(0, MAX_ROWS)\n .map((row) =>\n row !== null && typeof row === \"object\" && !Array.isArray(row)\n ? (row as Record<string, unknown>)\n : { value: row },\n );\n\n // Union of the keys across the sample, so binding menus list everything.\n const keys = [...new Set(rows.flatMap((row) => Object.keys(row)))].sort();\n return { rows, keys };\n}\n\n/** Reads one binding path off a row and flattens it to something renderable. */\nexport function readBinding(row: Record<string, unknown>, path: string): string {\n const value = selectPath(row, path);\n if (value === null || value === undefined) return \"\";\n if (typeof value === \"object\") return \"\";\n return String(value);\n}\n\n/* ------------------------------------------------------------------ tokens */\n\n/**\n * Resolves `{{query.id}}`, `{{params.slug}}` and `{{page.slug}}`.\n *\n * Only these two namespaces exist: an unknown token becomes an empty string\n * rather than being left in the URL, so a typo cannot send a literal `{{\u2026}}`\n * to the API or accidentally expose something else.\n */\nexport function resolveTokens(value: string, context: RequestContext): string {\n return value.replace(/\\{\\{\\s*([a-zA-Z0-9_.]+)\\s*\\}\\}/g, (_, token: string) => {\n const [namespace, ...rest] = token.split(\".\");\n const key = rest.join(\".\");\n if (namespace === \"query\") return context.query[key] ?? \"\";\n if (namespace === \"params\") return context.params[key] ?? \"\";\n if (namespace === \"page\" && key === \"slug\") return context.page.slug;\n return \"\";\n });\n}\n\n/** Header names and values must not be able to inject extra headers. */\nfunction safeHeader(pair: RequestPair, context: RequestContext) {\n const name = pair.key.trim();\n if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name)) return null;\n // Strip anything that could terminate the header line.\n const value = resolveTokens(pair.value, context).replace(/[\\r\\n\\0]/g, \"\").trim();\n return value ? ([name, value] as const) : null;\n}\n\n/**\n * Builds the final URL: tokens resolved, params appended and percent-encoded\n * by `URLSearchParams` so a value can never break out into the query grammar.\n */\nexport function buildRequest(source: DataSource, context: RequestContext) {\n const url = assertFetchableUrl(resolveTokens(source.url, context));\n\n for (const pair of source.params ?? []) {\n const key = pair.key.trim();\n if (!key) continue;\n const value = resolveTokens(pair.value, context);\n // An empty optional filter is dropped rather than sent as `key=`.\n if (value === \"\") continue;\n url.searchParams.set(key, value);\n }\n\n const method: HttpMethod = source.method ?? \"GET\";\n\n const headers: Record<string, string> = {\n accept: \"application/json,text/plain;q=0.9,*/*;q=0.8\",\n };\n\n let body: string | undefined;\n if (sendsBody(method) && source.body?.trim()) {\n body = resolveTokens(source.body, context);\n headers[\"content-type\"] = \"application/json\";\n }\n\n // Author headers last, so they can override the defaults above.\n for (const pair of source.headers ?? []) {\n const safe = safeHeader(pair, context);\n if (safe) headers[safe[0]] = safe[1];\n }\n\n // Re-check after tokens: a token could have rewritten the host.\n return { url: assertFetchableUrl(url.toString()), headers, method, body };\n}\n\nexport const EMPTY_CONTEXT: RequestContext = { query: {}, params: {}, page: { slug: \"\" } };\n", "import { DataSourceError, type LoadOptions, loadSource } from \"@/lib/data/source\";\nimport type { CanvasElement, DataSource } from \"@/lib/editor/types\";\nimport type { PageData } from \"./bind\";\n\n/** Signals that a source elected to turn its upstream 404 into a page 404. */\nexport class PageDataNotFoundError extends Error {\n constructor(public readonly sourceId: string) {\n super(`Data source ${sourceId} returned 404.`);\n this.name = \"PageDataNotFoundError\";\n }\n}\n\n/**\n * Fetches every source a page actually uses. Sources nothing references are\n * skipped, and a source that fails yields no rows rather than failing the\n * whole page \u2014 a broken API should not take the site down.\n */\nexport async function loadPageData(\n elements: CanvasElement[],\n sources: DataSource[],\n options?: LoadOptions,\n): Promise<PageData> {\n const used = new Set(\n elements\n .filter((el) => el.sourceId)\n .map((el) => el.sourceId as string),\n );\n const wanted = sources.filter((source) => used.has(source.id) && source.url);\n if (wanted.length === 0) return {};\n\n const results = await Promise.all(\n wanted.map(async (source) => {\n try {\n const { rows } = await loadSource(source, options);\n return [source.id, rows] as const;\n } catch (error) {\n if (\n error instanceof DataSourceError &&\n error.status === 404 &&\n source.onNotFound === \"page-404\"\n ) {\n throw new PageDataNotFoundError(source.id);\n }\n console.warn(\n `Data source \"${source.name}\" failed:`,\n error instanceof DataSourceError ? error.message : error,\n );\n return [source.id, []] as const;\n }\n }),\n );\n\n return Object.fromEntries(results);\n}\n"],
4
+ "sourcesContent": ["export const ELEMENT_TYPES = [\n \"Frame\",\n \"Stack\",\n \"Section\",\n \"Container\",\n \"Grid\",\n \"Heading\",\n \"Text\",\n \"Image\",\n \"Button\",\n \"Video\",\n \"Icon\",\n \"Form\",\n \"Input\",\n \"Textarea\",\n \"Request\",\n \"Repeat\",\n] as const;\n\nexport type ElementType = (typeof ELEMENT_TYPES)[number];\n\n/** One key/value pair on a request; values may carry `{{\u2026}}` tokens. */\nexport type RequestPair = { key: string; value: string };\n\nexport const HTTP_METHODS = [\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"] as const;\nexport type HttpMethod = (typeof HTTP_METHODS)[number];\n\nexport const DATA_SOURCE_NOT_FOUND_BEHAVIORS = [\"empty\", \"page-404\"] as const;\nexport type DataSourceNotFoundBehavior = (typeof DATA_SOURCE_NOT_FOUND_BEHAVIORS)[number];\n\n/** Methods that carry a request body. */\nexport function sendsBody(method: HttpMethod) {\n return method === \"POST\" || method === \"PUT\" || method === \"PATCH\";\n}\n\n/**\n * A JSON endpoint the page pulls content from. Sources live on the page so a\n * Repeat block can name one without carrying the URL on every element.\n */\nexport type DataSource = {\n id: string;\n name: string;\n url: string;\n /** Dotted path to the array inside the payload; \"\" when it is the root. */\n path: string;\n method?: HttpMethod;\n /** JSON body for POST/PUT/PATCH; tokens are resolved before sending. */\n body?: string;\n /** Appended to the URL as a query string. */\n params?: RequestPair[];\n /** Sent as request headers \u2014 for API keys and the like. */\n headers?: RequestPair[];\n /** Controls whether an upstream 404 empties this source or rejects the whole page. */\n onNotFound?: DataSourceNotFoundBehavior;\n};\n\n/**\n * Values a request token can read. Everything else resolves to an empty\n * string, so a typo cannot leak an unrelated value into the URL.\n */\nexport type RequestContext = {\n /** The visitor's query string, e.g. `{{query.id}}` on /post?id=5. */\n query: Record<string, string>;\n /** Values captured by a dynamic page path, e.g. `{{params.slug}}`. */\n params: Record<string, string>;\n /** The page being rendered, e.g. `{{page.slug}}`. */\n page: { slug: string };\n};\n\nexport type ResizeHandle = \"nw\" | \"ne\" | \"sw\" | \"se\" | \"n\" | \"s\" | \"w\" | \"e\";\n\n/* ------------------------------------------------------------- breakpoints */\n\nexport const BREAKPOINTS = [\"desktop\", \"tablet\", \"mobile\"] as const;\nexport type Breakpoint = string;\n\nexport type BreakpointDefinition = {\n id: string;\n name: string;\n width: number;\n};\n\nexport const DEFAULT_BREAKPOINTS: BreakpointDefinition[] = [\n { id: \"desktop\", name: \"Desktop\", width: 1280 },\n { id: \"tablet\", name: \"Tablet\", width: 768 },\n { id: \"mobile\", name: \"Mobile\", width: 375 },\n];\n\nexport const BREAKPOINT_WIDTHS: Record<string, number> = {\n desktop: 1280,\n tablet: 768,\n mobile: 375,\n};\n\n/**\n * Styles cascade from the widest breakpoint down, so a value set on desktop\n * holds everywhere until a narrower breakpoint overrides it.\n */\nexport const BREAKPOINT_CHAIN: Record<string, Breakpoint[]> = {\n desktop: [\"desktop\"],\n tablet: [\"desktop\", \"tablet\"],\n mobile: [\"desktop\", \"tablet\", \"mobile\"],\n};\n\n/* ------------------------------------------------------------------ styles */\n\n/** `fill` stretches to the parent, `auto` shrinks to the content. */\nexport type SizeMode = \"fixed\" | \"fill\" | \"auto\" | \"screen\";\nexport type Constraint = \"start\" | \"center\" | \"end\" | \"stretch\";\n/** `absolute` positions children by x/y; `stack` lays them out with flexbox. */\nexport type LayoutMode = \"absolute\" | \"stack\";\nexport type Direction = \"row\" | \"column\";\nexport type Justify = \"start\" | \"center\" | \"end\" | \"between\";\nexport type Align = \"start\" | \"center\" | \"end\" | \"stretch\";\nexport type TextAlign = \"left\" | \"center\" | \"right\" | \"justify\";\nexport type TextTransform = \"none\" | \"uppercase\" | \"lowercase\" | \"capitalize\";\nexport type ObjectFit = \"cover\" | \"contain\" | \"fill\" | \"none\";\nexport type Overflow = \"visible\" | \"hidden\" | \"auto\" | \"scroll\";\nexport type Entrance = \"none\" | \"fade\" | \"up\" | \"down\" | \"left\" | \"right\" | \"zoom\";\nexport type MotionCurve = \"ease\" | \"spring\";\nexport type CursorStyle = \"auto\" | \"default\" | \"pointer\" | \"text\" | \"grab\" | \"zoom-in\" | \"none\";\n/**\n * How an element sits relative to its siblings.\n *\n * `absolute` is per element, not per container: it lifts this one out of the\n * flow and places it at x/y while everything around it keeps stacking. Making\n * the whole parent free instead would move every sibling to satisfy one of\n * them.\n */\nexport type PositionMode = \"static\" | \"sticky\" | \"fixed\" | \"absolute\";\n/** Which edge a pinned element holds to. */\nexport type PinSide = \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type BgSize = \"cover\" | \"contain\" | \"auto\";\nexport type BlendMode =\n | \"normal\"\n | \"multiply\"\n | \"screen\"\n | \"overlay\"\n | \"darken\"\n | \"lighten\"\n | \"difference\"\n | \"luminosity\";\nexport type BorderStyle = \"solid\" | \"dashed\" | \"dotted\";\n\nexport type ElementStyle = {\n // Box \u2014 x/y only apply inside an `absolute` parent.\n x: number;\n y: number;\n constraintX: Constraint;\n constraintY: Constraint;\n w: number;\n h: number;\n widthMode: SizeMode;\n heightMode: SizeMode;\n\n // How this element arranges its own children.\n layout: LayoutMode;\n direction: Direction;\n gap: number;\n padT: number;\n padR: number;\n padB: number;\n padL: number;\n /**\n * Space held below the element, outside its own box.\n *\n * Separate from `padB` on purpose: padding is the breathing room the\n * author gave the content inside a section, while this is the distance to\n * whatever comes next. Sharing one value would make adjusting the rhythm\n * between sections quietly reflow their insides.\n */\n marginB: number;\n justify: Justify;\n align: Align;\n wrap: boolean;\n /** Grid columns; only read when `layout` is `stack` on a Grid element. */\n columns: number;\n\n // Appearance\n bg: string;\n /** A full CSS gradient value, or \"\" for none. Painted over `bg`. */\n gradient: string;\n color: string;\n radius: number;\n opacity: number;\n borderW: number;\n /** Per-edge override; null inherits borderW. */\n borderT: number | null;\n borderR: number | null;\n borderB: number | null;\n borderL: number | null;\n borderC: string;\n borderStyle: BorderStyle;\n /** A full CSS box-shadow value, or \"\" for none. */\n shadow: string;\n rotate: number;\n\n // Typography\n fontFamily: string;\n fontSize: number;\n fontWeight: string;\n lineHeight: number;\n letterSpacing: number;\n textAlign: TextAlign;\n textTransform: TextTransform;\n\n // Composition \u2014 the pieces that make a layout feel designed rather than\n // stacked: clipping, sticky rails, imagery, glass and blend effects.\n overflow: Overflow;\n position: PositionMode;\n /** CSS stacking order, independent from the internal document order. */\n zIndex: number;\n /** Distance from `pinSide` while pinned; read when position is sticky or fixed. */\n stickyOffset: number;\n /** Edge a sticky or fixed element pins to. */\n pinSide: PinSide;\n /** Background image URL, or \"\" for none. Painted over `gradient`. */\n bgImage: string;\n bgSize: BgSize;\n bgPosition: string;\n /** Blurs the element's own content, in px. */\n blur: number;\n /** Blurs whatever sits behind the element, in px \u2014 the glass effect. */\n backdropBlur: number;\n blendMode: BlendMode;\n /** Percent; 100 leaves the element alone. */\n scale: number;\n /** A CSS ratio such as \"16/9\", or \"\" to leave height to the layout. */\n aspectRatio: string;\n\n /** Entrance effect, played once when the element scrolls into view. */\n entrance: Entrance;\n /** Milliseconds. */\n entranceDuration: number;\n entranceDelay: number;\n entranceCurve: MotionCurve;\n entranceBezier: string;\n springStiffness: number;\n springDamping: number;\n cursor: CursorStyle;\n\n /** Hidden at this breakpoint. */\n hidden: boolean;\n};\n\nexport const STYLE_KEYS = [\n \"x\",\n \"y\",\n \"constraintX\",\n \"constraintY\",\n \"w\",\n \"h\",\n \"widthMode\",\n \"heightMode\",\n \"layout\",\n \"direction\",\n \"gap\",\n \"padT\",\n \"padR\",\n \"padB\",\n \"padL\",\n \"marginB\",\n \"justify\",\n \"align\",\n \"wrap\",\n \"columns\",\n \"bg\",\n \"gradient\",\n \"color\",\n \"radius\",\n \"opacity\",\n \"borderW\",\n \"borderT\",\n \"borderR\",\n \"borderB\",\n \"borderL\",\n \"borderC\",\n \"borderStyle\",\n \"shadow\",\n \"rotate\",\n \"fontFamily\",\n \"fontSize\",\n \"fontWeight\",\n \"lineHeight\",\n \"letterSpacing\",\n \"textAlign\",\n \"textTransform\",\n \"overflow\",\n \"position\",\n \"zIndex\",\n \"stickyOffset\",\n \"pinSide\",\n \"bgImage\",\n \"bgSize\",\n \"bgPosition\",\n \"blur\",\n \"backdropBlur\",\n \"blendMode\",\n \"scale\",\n \"aspectRatio\",\n \"entrance\",\n \"entranceDuration\",\n \"entranceDelay\",\n \"entranceCurve\",\n \"entranceBezier\",\n \"springStiffness\",\n \"springDamping\",\n \"cursor\",\n \"hidden\",\n] as const satisfies ReadonlyArray<keyof ElementStyle>;\n\nexport type StyleKey = (typeof STYLE_KEYS)[number];\n\nexport type CanvasElement = {\n id: string;\n type: ElementType;\n name?: string;\n parentId?: string;\n z: number;\n locked?: boolean;\n /** Page-local reusable component metadata. */\n componentRole?: \"master\" | \"instance\";\n componentId?: string;\n componentSourceId?: string;\n variant?: string;\n styleBindings?: Partial<Record<StyleKey, string>>;\n\n // Content is shared across breakpoints.\n content?: string;\n /** Sandboxed HTML/CSS used by code components. */\n code?: string;\n src?: string;\n alt?: string;\n objectFit?: ObjectFit;\n iconName?: PagieraIconName;\n placeholder?: string;\n fieldName?: string;\n inputType?: \"text\" | \"email\" | \"password\" | \"number\" | \"tel\" | \"url\" | \"search\";\n required?: boolean;\n formAction?: string;\n formMethod?: HttpMethod;\n formSubmitMode?: \"request\" | \"native\";\n formContentType?: \"json\" | \"form-data\" | \"urlencoded\";\n /** Optional request body. `{{form.email}}` tokens read submitted fields. */\n formBody?: string;\n /** One `Header: value` pair per line. */\n formHeaders?: string;\n formSuccessMessage?: string;\n formErrorMessage?: string;\n formResetOnSuccess?: boolean;\n buttonType?: \"button\" | \"submit\" | \"reset\";\n href?: string;\n target?: \"_self\" | \"_blank\";\n interaction?: {\n trigger: \"click\";\n action: \"navigate\" | \"scroll-to\" | \"toggle-layer\" | \"show-layer\" | \"hide-layer\";\n value: string;\n target?: \"_self\" | \"_blank\";\n };\n\n /** Data source read by a Request/Repeat block or a directly-bound element. */\n sourceId?: string;\n /**\n * Inside Request/Repeat, pulls this field off the current object instead\n * of using the element's content. Dotted paths work: \"author.name\".\n */\n binding?: string;\n\n /** Desktop values; every breakpoint falls back to these. */\n base: ElementStyle;\n /** Narrower-breakpoint deltas, applied over `base` in cascade order. */\n overrides?: Record<string, Partial<ElementStyle>>;\n /** Applied on pointer hover, on top of the resolved breakpoint style. */\n hover?: Partial<ElementStyle>;\n /** Whether hover is activated by this node or its immediate parent. */\n hoverTrigger?: \"self\" | \"parent\";\n /** Applied while the pointer is pressed. */\n press?: Partial<ElementStyle>;\n loop?: { type: \"pulse\" | \"float\" | \"spin\"; duration: number };\n draggable?: boolean;\n};\n\n/* ---------------------------------------------------------------- defaults */\n\nexport const BASE_STYLE: ElementStyle = {\n x: 0,\n y: 0,\n constraintX: \"start\",\n constraintY: \"start\",\n w: 200,\n h: 100,\n widthMode: \"fixed\",\n heightMode: \"fixed\",\n\n layout: \"absolute\",\n direction: \"column\",\n gap: 0,\n padT: 0,\n padR: 0,\n padB: 0,\n padL: 0,\n marginB: 0,\n justify: \"start\",\n align: \"start\",\n wrap: false,\n columns: 3,\n\n bg: \"transparent\",\n gradient: \"\",\n color: \"#27272a\",\n radius: 0,\n opacity: 100,\n borderW: 0,\n borderT: null,\n borderR: null,\n borderB: null,\n borderL: null,\n borderC: \"transparent\",\n borderStyle: \"solid\",\n shadow: \"\",\n rotate: 0,\n\n fontFamily: \"inherit\",\n fontSize: 16,\n fontWeight: \"normal\",\n lineHeight: 1.5,\n letterSpacing: 0,\n textAlign: \"left\",\n textTransform: \"none\",\n\n overflow: \"visible\",\n position: \"static\",\n zIndex: 0,\n stickyOffset: 0,\n pinSide: \"top\",\n bgImage: \"\",\n bgSize: \"cover\",\n bgPosition: \"center\",\n blur: 0,\n backdropBlur: 0,\n blendMode: \"normal\",\n scale: 100,\n aspectRatio: \"\",\n\n entrance: \"none\",\n entranceDuration: 600,\n entranceDelay: 0,\n entranceCurve: \"ease\",\n entranceBezier: \"0.44, 0, 0.56, 1\",\n springStiffness: 300,\n springDamping: 30,\n cursor: \"auto\",\n\n hidden: false,\n};\n\nexport function makeStyle(overrides: Partial<ElementStyle>): ElementStyle {\n return { ...BASE_STYLE, ...overrides };\n}\n\n/** Every field a freshly dropped element starts with. */\nexport const ELEMENT_DEFAULTS: Record<\n ElementType,\n { style: Partial<ElementStyle>; props?: Partial<CanvasElement> }\n> = {\n Frame: {\n style: {\n w: 640,\n h: 420,\n widthMode: \"fixed\",\n heightMode: \"fixed\",\n layout: \"absolute\",\n direction: \"column\",\n overflow: \"hidden\",\n bg: \"#ffffff\",\n borderW: 1,\n borderC: \"#e4e4e7\",\n radius: 12,\n },\n },\n Stack: {\n style: {\n w: 600,\n h: 160,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 16,\n padT: 0,\n padR: 0,\n padB: 0,\n padL: 0,\n justify: \"start\",\n align: \"stretch\",\n bg: \"transparent\",\n },\n },\n Section: {\n style: {\n w: 1280,\n h: 480,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 24,\n padT: 64,\n padR: 48,\n padB: 64,\n padL: 48,\n justify: \"start\",\n align: \"stretch\",\n bg: \"#ffffff\",\n },\n },\n Container: {\n style: {\n w: 600,\n h: 240,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 16,\n padT: 24,\n padR: 24,\n padB: 24,\n padL: 24,\n align: \"stretch\",\n bg: \"transparent\",\n borderW: 1,\n borderC: \"#e4e4e7\",\n radius: 12,\n },\n },\n Grid: {\n style: {\n w: 900,\n h: 300,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"row\",\n columns: 3,\n gap: 24,\n align: \"stretch\",\n },\n },\n Heading: {\n style: {\n w: 480,\n h: 48,\n widthMode: \"fill\",\n heightMode: \"auto\",\n fontSize: 40,\n fontWeight: \"bold\",\n lineHeight: 1.2,\n letterSpacing: -0.5,\n color: \"#18181b\",\n },\n props: { content: \"Heading\" },\n },\n Text: {\n style: {\n w: 480,\n h: 24,\n widthMode: \"fill\",\n heightMode: \"auto\",\n fontSize: 16,\n lineHeight: 1.6,\n color: \"#52525b\",\n },\n props: { content: \"Text block content\" },\n },\n Image: {\n style: { w: 400, h: 260, widthMode: \"fill\", bg: \"#e4e4e7\", radius: 12 },\n props: { src: \"\", alt: \"\", objectFit: \"cover\" },\n },\n Button: {\n style: {\n w: 140,\n h: 44,\n widthMode: \"auto\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"row\",\n justify: \"center\",\n align: \"center\",\n padT: 12,\n padR: 22,\n padB: 12,\n padL: 22,\n bg: \"#2563eb\",\n color: \"#ffffff\",\n radius: 8,\n fontSize: 15,\n fontWeight: \"500\",\n textAlign: \"center\",\n },\n props: { content: \"Button\", href: \"\", target: \"_self\", buttonType: \"button\" },\n },\n Video: {\n style: { w: 640, h: 360, widthMode: \"fill\", bg: \"#18181b\", radius: 12 },\n props: { src: \"\" },\n },\n Icon: {\n style: { w: 24, h: 24, widthMode: \"fixed\", heightMode: \"fixed\", color: \"#5402E6\" },\n props: { iconName: \"star\" },\n },\n Form: {\n style: {\n w: 560,\n h: 240,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 14,\n align: \"stretch\",\n },\n props: {\n formAction: \"\",\n formMethod: \"POST\",\n formSubmitMode: \"request\",\n formContentType: \"json\",\n formSuccessMessage: \"Sent successfully.\",\n formErrorMessage: \"Something went wrong.\",\n },\n },\n Input: {\n style: {\n w: 320,\n h: 46,\n widthMode: \"fill\",\n heightMode: \"fixed\",\n padT: 0,\n padR: 14,\n padB: 0,\n padL: 14,\n bg: \"#ffffff\",\n color: \"#18181b\",\n borderW: 1,\n borderC: \"#d4d4d8\",\n radius: 10,\n fontSize: 15,\n },\n props: { placeholder: \"Enter a value\u2026\", fieldName: \"field\", inputType: \"text\" },\n },\n Textarea: {\n style: {\n w: 320,\n h: 120,\n widthMode: \"fill\",\n heightMode: \"fixed\",\n padT: 12,\n padR: 14,\n padB: 12,\n padL: 14,\n bg: \"#ffffff\",\n color: \"#18181b\",\n borderW: 1,\n borderC: \"#d4d4d8\",\n radius: 10,\n fontSize: 15,\n },\n props: { placeholder: \"Write your message\u2026\", fieldName: \"message\" },\n },\n Request: {\n style: {\n w: 900,\n h: 200,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"column\",\n gap: 16,\n align: \"stretch\",\n },\n props: { sourceId: \"\" },\n },\n Repeat: {\n style: {\n w: 900,\n h: 200,\n widthMode: \"fill\",\n heightMode: \"auto\",\n layout: \"stack\",\n direction: \"row\",\n gap: 24,\n align: \"stretch\",\n wrap: true,\n },\n props: { sourceId: \"\" },\n },\n};\n\n/* ----------------------------------------------------------------- helpers */\n\nconst CONTAINER_TYPES: ReadonlySet<ElementType> = new Set([\n \"Frame\",\n \"Stack\",\n \"Section\",\n \"Container\",\n \"Grid\",\n \"Button\",\n \"Form\",\n \"Request\",\n \"Repeat\",\n]);\n\n/** Whether an element can hold children. */\nexport function isContainer(type: ElementType) {\n return CONTAINER_TYPES.has(type);\n}\n\nconst TEXTUAL_TYPES: ReadonlySet<ElementType> = new Set([\n \"Heading\",\n \"Text\",\n \"Button\",\n]);\n\n/** Whether an element renders editable text. */\nexport function isTextual(type: ElementType) {\n return TEXTUAL_TYPES.has(type);\n}\n\n/** Page-level settings; the canvas behaves as the root container. */\nexport type RootStyle = {\n documentMode: \"page\" | \"component\";\n /** Content is centred inside this width unless `fullWidth` is set. */\n maxWidth: number;\n /** Editable design-surface height; the public page can still grow with content. */\n canvasHeight: number;\n /** Let content run edge to edge instead of being capped at `maxWidth`. */\n fullWidth: boolean;\n bg: string;\n layout: LayoutMode;\n direction: Direction;\n gap: number;\n padT: number;\n padR: number;\n padB: number;\n padL: number;\n align: Align;\n fontFamily: string;\n /** Cross-document animation used by links on the published site. */\n pageTransition: \"smooth\" | \"fade\" | \"slide\" | \"none\";\n /** Duration of the incoming published-page animation in milliseconds. */\n pageTransitionDuration: number;\n /** User-defined viewport previews, ordered from widest to narrowest. */\n breakpoints?: BreakpointDefinition[];\n /** The breakpoint whose values live in every element's `base` style. */\n baseBreakpointId?: string;\n variables?: DesignVariable[];\n customFonts?: CustomFont[];\n /**\n * Raw CSS appended after the generated sheet, so it can override any rule\n * the builder produced. Escape hatch for what the inspector cannot express.\n */\n customCss?: string;\n /**\n * Raw JavaScript run on the published page, after the document parses.\n *\n * It is deliberately never executed inside the editor: the canvas and the\n * template preview both render without scripting, so a page cannot reach\n * the editor it is being built in.\n */\n customJs?: string;\n};\n\nexport type CustomFont = { id: string; name: string; url: string; weight: number; style: \"normal\" | \"italic\" };\n\nexport type DesignVariable = {\n id: string;\n name: string;\n type: \"color\" | \"number\";\n value: string | number;\n};\n\nexport const DEFAULT_ROOT_STYLE: RootStyle = {\n documentMode: \"page\",\n maxWidth: 1280,\n canvasHeight: 800,\n fullWidth: false,\n bg: \"#ffffff\",\n layout: \"stack\",\n direction: \"column\",\n gap: 0,\n padT: 0,\n padR: 0,\n padB: 0,\n padL: 0,\n align: \"stretch\",\n fontFamily: \"inherit\",\n pageTransition: \"smooth\",\n pageTransitionDuration: 380,\n};\n\nexport const FONT_STACKS: Array<{ label: string; value: string }> = [\n { label: \"Inherit\", value: \"inherit\" },\n { label: \"Sans\", value: \"ui-sans-serif, system-ui, sans-serif\" },\n { label: \"Serif\", value: \"ui-serif, Georgia, serif\" },\n { label: \"Mono\", value: \"ui-monospace, SFMono-Regular, monospace\" },\n];\n\nexport const SHADOW_PRESETS: Array<{ label: string; value: string }> = [\n { label: \"None\", value: \"\" },\n { label: \"Small\", value: \"0 1px 2px rgba(0,0,0,0.06)\" },\n { label: \"Medium\", value: \"0 4px 12px rgba(0,0,0,0.08)\" },\n { label: \"Large\", value: \"0 12px 32px rgba(0,0,0,0.12)\" },\n { label: \"Glow\", value: \"0 0 0 4px rgba(37,99,235,0.15)\" },\n];\n\nexport const ASPECT_RATIOS: Array<{ label: string; value: string }> = [\n { label: \"Free\", value: \"\" },\n { label: \"Square 1:1\", value: \"1/1\" },\n { label: \"Photo 4:3\", value: \"4/3\" },\n { label: \"Wide 16:9\", value: \"16/9\" },\n { label: \"Ultra 21:9\", value: \"21/9\" },\n { label: \"Portrait 3:4\", value: \"3/4\" },\n];\n\nexport const ENTRANCES: Array<{ label: string; value: Entrance }> = [\n { label: \"None\", value: \"none\" },\n { label: \"Fade in\", value: \"fade\" },\n { label: \"Rise up\", value: \"up\" },\n { label: \"Drop down\", value: \"down\" },\n { label: \"Slide from left\", value: \"left\" },\n { label: \"Slide from right\", value: \"right\" },\n { label: \"Zoom in\", value: \"zoom\" },\n];\n\nexport const DRAG_MIME = \"application/pagiera-element-type\";\n/** Set when dragging an element that already exists on the canvas. */\nexport const MOVE_MIME = \"application/pagiera-element-id\";\nimport type { PagieraIconName } from \"../../../icon-names\";\n", "import {\n type DataSource,\n type HttpMethod,\n type RequestContext,\n type RequestPair,\n sendsBody,\n} from \"@/lib/editor/types\";\n\n/**\n * Fetching an author-supplied URL from our server is a server-side request\n * forgery vector: without checks it can reach cloud metadata endpoints, admin\n * services on localhost, or anything else inside the network perimeter. Every\n * hop is validated against this list, and redirects are followed by hand so a\n * public URL cannot bounce into private space.\n */\nconst BLOCKED_HOSTS = new Set([\n \"localhost\",\n \"127.0.0.1\",\n \"0.0.0.0\",\n \"::1\",\n \"[::1]\",\n \"metadata.google.internal\",\n \"metadata.goog\",\n]);\n\nconst PRIVATE_IPV4 =\n /^(10\\.|127\\.|0\\.|169\\.254\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[01])\\.)/;\n\n/** Hostnames that only resolve inside a private network. */\nconst PRIVATE_SUFFIXES = [\".local\", \".internal\", \".localdomain\", \".home.arpa\"];\n\nexport const MAX_ROWS = 200;\nconst MAX_BYTES = 2_000_000;\nconst TIMEOUT_MS = 8000;\nconst MAX_REDIRECTS = 3;\n\nexport class DataSourceError extends Error {\n constructor(message: string, public readonly status?: number) {\n super(message);\n this.name = \"DataSourceError\";\n }\n}\n\nexport function assertFetchableUrl(raw: string): URL {\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n throw new DataSourceError(\"That is not a valid URL.\");\n }\n\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n throw new DataSourceError(\"Only http and https URLs can be requested.\");\n }\n\n const host = url.hostname.toLowerCase();\n if (BLOCKED_HOSTS.has(host) || PRIVATE_IPV4.test(host)) {\n throw new DataSourceError(`Requests to ${host} are not allowed.`);\n }\n if (PRIVATE_SUFFIXES.some((suffix) => host.endsWith(suffix))) {\n throw new DataSourceError(`Requests to ${host} are not allowed.`);\n }\n // Bracketed IPv6 literals: anything link-local or unique-local.\n if (host.startsWith(\"[\") && /^\\[(fe80|fc|fd)/i.test(host)) {\n throw new DataSourceError(\"Requests to private addresses are not allowed.\");\n }\n\n return url;\n}\n\n/**\n * Walks a dotted path such as `data.items` or `results.0.tags`. An empty path\n * returns the payload itself, which is what a bare JSON array needs.\n */\nexport function selectPath(payload: unknown, path: string): unknown {\n const steps = path.split(\".\").map((s) => s.trim()).filter(Boolean);\n let cursor: unknown = payload;\n for (const step of steps) {\n if (cursor === null || typeof cursor !== \"object\") return undefined;\n cursor = (cursor as Record<string, unknown>)[step];\n }\n return cursor;\n}\n\n/** Reads a body with a hard cap so a huge response cannot exhaust memory. */\nasync function readCapped(response: Response): Promise<string> {\n const reader = response.body?.getReader();\n if (!reader) return \"\";\n\n const chunks: Uint8Array[] = [];\n let total = 0;\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n total += value.byteLength;\n if (total > MAX_BYTES) {\n await reader.cancel();\n throw new DataSourceError(\"That response is too large (over 2 MB).\");\n }\n chunks.push(value);\n }\n return new TextDecoder().decode(\n chunks.reduce<Uint8Array>((acc, chunk) => {\n const merged = new Uint8Array(acc.length + chunk.length);\n merged.set(acc);\n merged.set(chunk, acc.length);\n return merged;\n }, new Uint8Array()),\n );\n}\n\nexport type SourceResult = { rows: Array<Record<string, unknown>>; keys: string[] };\n\n/**\n * Requests a source and normalizes it to rows. A root object becomes one row\n * for Request; an array stays multiple rows for Repeat.\n */\nexport type LoadOptions = {\n /**\n * Seconds to cache the response for. `false` bypasses the cache \u2014 only for\n * the editor's Test button; on a rendered route it would force an\n * otherwise-static page to become dynamic on every request.\n */\n revalidate?: number | false;\n /** Supplies `{{query.\u2026}}` / `{{page.\u2026}}` values for this request. */\n context?: RequestContext;\n};\n\nexport const DEFAULT_REVALIDATE = 60;\n\nexport async function loadSource(\n source: DataSource,\n { revalidate = DEFAULT_REVALIDATE, context = EMPTY_CONTEXT }: LoadOptions = {},\n): Promise<SourceResult> {\n const request = buildRequest(source, context);\n let target = request.url;\n\n let response: Response | undefined;\n for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {\n response = await fetch(target, {\n // Redirects are followed by hand so each hop is validated too.\n redirect: \"manual\",\n method: request.method,\n headers: request.headers,\n body: request.body,\n signal: AbortSignal.timeout(TIMEOUT_MS),\n // Only GET responses are cacheable; anything with side effects is\n // fetched fresh every time.\n ...(revalidate === false || request.method !== \"GET\"\n ? { cache: \"no-store\" as const }\n : { next: { revalidate } }),\n });\n\n if (response.status < 300 || response.status >= 400) break;\n\n const location = response.headers.get(\"location\");\n if (!location) break;\n target = assertFetchableUrl(new URL(location, target).toString());\n response = undefined;\n }\n\n if (!response) throw new DataSourceError(\"Too many redirects.\");\n if (!response.ok) {\n throw new DataSourceError(`The API replied ${response.status}.`, response.status);\n }\n\n const text = await readCapped(response);\n let payload: unknown;\n try {\n payload = JSON.parse(text);\n } catch {\n throw new DataSourceError(\"The response was not JSON.\");\n }\n\n const selected = selectPath(payload, source.path);\n const selectedRows = Array.isArray(selected)\n ? selected\n : selected !== null && typeof selected === \"object\"\n ? [selected]\n : undefined;\n if (!selectedRows) {\n throw new DataSourceError(\n source.path\n ? `\"${source.path}\" is not a list in that response.`\n : \"That response is neither an object nor a list.\",\n );\n }\n\n const rows = selectedRows\n .slice(0, MAX_ROWS)\n .map((row) =>\n row !== null && typeof row === \"object\" && !Array.isArray(row)\n ? (row as Record<string, unknown>)\n : { value: row },\n );\n\n // Union of the keys across the sample, so binding menus list everything.\n const keys = [...new Set(rows.flatMap((row) => Object.keys(row)))].sort();\n return { rows, keys };\n}\n\n/** Reads one binding path off a row and flattens it to something renderable. */\nexport function readBinding(row: Record<string, unknown>, path: string): string {\n const value = selectPath(row, path);\n if (value === null || value === undefined) return \"\";\n if (typeof value === \"object\") return \"\";\n return String(value);\n}\n\n/* ------------------------------------------------------------------ tokens */\n\n/**\n * Resolves `{{query.id}}`, `{{params.slug}}` and `{{page.slug}}`.\n *\n * Only these two namespaces exist: an unknown token becomes an empty string\n * rather than being left in the URL, so a typo cannot send a literal `{{\u2026}}`\n * to the API or accidentally expose something else.\n */\nexport function resolveTokens(value: string, context: RequestContext): string {\n return value.replace(/\\{\\{\\s*([a-zA-Z0-9_.]+)\\s*\\}\\}/g, (_, token: string) => {\n const [namespace, ...rest] = token.split(\".\");\n const key = rest.join(\".\");\n if (namespace === \"query\") return context.query[key] ?? \"\";\n if (namespace === \"params\") return context.params[key] ?? \"\";\n if (namespace === \"page\" && key === \"slug\") return context.page.slug;\n return \"\";\n });\n}\n\n/** Header names and values must not be able to inject extra headers. */\nfunction safeHeader(pair: RequestPair, context: RequestContext) {\n const name = pair.key.trim();\n if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name)) return null;\n // Strip anything that could terminate the header line.\n const value = resolveTokens(pair.value, context).replace(/[\\r\\n\\0]/g, \"\").trim();\n return value ? ([name, value] as const) : null;\n}\n\n/**\n * Builds the final URL: tokens resolved, params appended and percent-encoded\n * by `URLSearchParams` so a value can never break out into the query grammar.\n */\nexport function buildRequest(source: DataSource, context: RequestContext) {\n const url = assertFetchableUrl(resolveTokens(source.url, context));\n\n for (const pair of source.params ?? []) {\n const key = pair.key.trim();\n if (!key) continue;\n const value = resolveTokens(pair.value, context);\n // An empty optional filter is dropped rather than sent as `key=`.\n if (value === \"\") continue;\n url.searchParams.set(key, value);\n }\n\n const method: HttpMethod = source.method ?? \"GET\";\n\n const headers: Record<string, string> = {\n accept: \"application/json,text/plain;q=0.9,*/*;q=0.8\",\n };\n\n let body: string | undefined;\n if (sendsBody(method) && source.body?.trim()) {\n body = resolveTokens(source.body, context);\n headers[\"content-type\"] = \"application/json\";\n }\n\n // Author headers last, so they can override the defaults above.\n for (const pair of source.headers ?? []) {\n const safe = safeHeader(pair, context);\n if (safe) headers[safe[0]] = safe[1];\n }\n\n // Re-check after tokens: a token could have rewritten the host.\n return { url: assertFetchableUrl(url.toString()), headers, method, body };\n}\n\nexport const EMPTY_CONTEXT: RequestContext = { query: {}, params: {}, page: { slug: \"\" } };\n", "import { DataSourceError, type LoadOptions, loadSource } from \"@/lib/data/source\";\nimport type { CanvasElement, DataSource } from \"@/lib/editor/types\";\nimport type { PageData } from \"./bind\";\n\n/** Signals that a source elected to turn its upstream 404 into a page 404. */\nexport class PageDataNotFoundError extends Error {\n constructor(public readonly sourceId: string) {\n super(`Data source ${sourceId} returned 404.`);\n this.name = \"PageDataNotFoundError\";\n }\n}\n\n/**\n * Fetches every source a page actually uses. Sources nothing references are\n * skipped, and a source that fails yields no rows rather than failing the\n * whole page \u2014 a broken API should not take the site down.\n */\nexport async function loadPageData(\n elements: CanvasElement[],\n sources: DataSource[],\n options?: LoadOptions,\n): Promise<PageData> {\n const used = new Set(\n elements\n .filter((el) => el.sourceId)\n .map((el) => el.sourceId as string),\n );\n const wanted = sources.filter((source) => used.has(source.id) && source.url);\n if (wanted.length === 0) return {};\n\n const results = await Promise.all(\n wanted.map(async (source) => {\n try {\n const { rows } = await loadSource(source, options);\n return [source.id, rows] as const;\n } catch (error) {\n if (\n error instanceof DataSourceError &&\n error.status === 404 &&\n source.onNotFound === \"page-404\"\n ) {\n throw new PageDataNotFoundError(source.id);\n }\n console.warn(\n `Data source \"${source.name}\" failed:`,\n error instanceof DataSourceError ? error.message : error,\n );\n return [source.id, []] as const;\n }\n }),\n );\n\n return Object.fromEntries(results);\n}\n"],
5
5
  "mappings": ";AA+BO,SAAS,UAAU,QAAoB;AAC1C,SAAO,WAAW,UAAU,WAAW,SAAS,WAAW;AAC/D;;;AClBA,IAAM,gBAAgB,oBAAI,IAAI;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACJ,CAAC;AAED,IAAM,eACF;AAGJ,IAAM,mBAAmB,CAAC,UAAU,aAAa,gBAAgB,YAAY;AAEtE,IAAM,WAAW;AACxB,IAAM,YAAY;AAClB,IAAM,aAAa;AACnB,IAAM,gBAAgB;AAEf,IAAM,kBAAN,cAA8B,MAAM;AAAA,EACvC,YAAY,SAAiC,QAAiB;AAC1D,UAAM,OAAO;AAD4B;AAEzC,SAAK,OAAO;AAAA,EAChB;AACJ;AAEO,SAAS,mBAAmB,KAAkB;AACjD,MAAI;AACJ,MAAI;AACA,UAAM,IAAI,IAAI,GAAG;AAAA,EACrB,QAAQ;AACJ,UAAM,IAAI,gBAAgB,0BAA0B;AAAA,EACxD;AAEA,MAAI,IAAI,aAAa,YAAY,IAAI,aAAa,SAAS;AACvD,UAAM,IAAI,gBAAgB,4CAA4C;AAAA,EAC1E;AAEA,QAAM,OAAO,IAAI,SAAS,YAAY;AACtC,MAAI,cAAc,IAAI,IAAI,KAAK,aAAa,KAAK,IAAI,GAAG;AACpD,UAAM,IAAI,gBAAgB,eAAe,IAAI,mBAAmB;AAAA,EACpE;AACA,MAAI,iBAAiB,KAAK,CAAC,WAAW,KAAK,SAAS,MAAM,CAAC,GAAG;AAC1D,UAAM,IAAI,gBAAgB,eAAe,IAAI,mBAAmB;AAAA,EACpE;AAEA,MAAI,KAAK,WAAW,GAAG,KAAK,mBAAmB,KAAK,IAAI,GAAG;AACvD,UAAM,IAAI,gBAAgB,gDAAgD;AAAA,EAC9E;AAEA,SAAO;AACX;AAMO,SAAS,WAAW,SAAkB,MAAuB;AAChE,QAAM,QAAQ,KAAK,MAAM,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,OAAO;AACjE,MAAI,SAAkB;AACtB,aAAW,QAAQ,OAAO;AACtB,QAAI,WAAW,QAAQ,OAAO,WAAW,SAAU,QAAO;AAC1D,aAAU,OAAmC,IAAI;AAAA,EACrD;AACA,SAAO;AACX;AAGA,eAAe,WAAW,UAAqC;AAC3D,QAAM,SAAS,SAAS,MAAM,UAAU;AACxC,MAAI,CAAC,OAAQ,QAAO;AAEpB,QAAM,SAAuB,CAAC;AAC9B,MAAI,QAAQ;AACZ,SAAO,MAAM;AACT,UAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK;AAC1C,QAAI,KAAM;AACV,aAAS,MAAM;AACf,QAAI,QAAQ,WAAW;AACnB,YAAM,OAAO,OAAO;AACpB,YAAM,IAAI,gBAAgB,yCAAyC;AAAA,IACvE;AACA,WAAO,KAAK,KAAK;AAAA,EACrB;AACA,SAAO,IAAI,YAAY,EAAE;AAAA,IACrB,OAAO,OAAmB,CAAC,KAAK,UAAU;AACtC,YAAM,SAAS,IAAI,WAAW,IAAI,SAAS,MAAM,MAAM;AACvD,aAAO,IAAI,GAAG;AACd,aAAO,IAAI,OAAO,IAAI,MAAM;AAC5B,aAAO;AAAA,IACX,GAAG,IAAI,WAAW,CAAC;AAAA,EACvB;AACJ;AAmBO,IAAM,qBAAqB;AAElC,eAAsB,WAClB,QACA,EAAE,aAAa,oBAAoB,UAAU,cAAc,IAAiB,CAAC,GACxD;AACrB,QAAM,UAAU,aAAa,QAAQ,OAAO;AAC5C,MAAI,SAAS,QAAQ;AAErB,MAAI;AACJ,WAAS,MAAM,GAAG,OAAO,eAAe,OAAO;AAC3C,eAAW,MAAM,MAAM,QAAQ;AAAA;AAAA,MAE3B,UAAU;AAAA,MACV,QAAQ,QAAQ;AAAA,MAChB,SAAS,QAAQ;AAAA,MACjB,MAAM,QAAQ;AAAA,MACd,QAAQ,YAAY,QAAQ,UAAU;AAAA;AAAA;AAAA,MAGtC,GAAI,eAAe,SAAS,QAAQ,WAAW,QACzC,EAAE,OAAO,WAAoB,IAC7B,EAAE,MAAM,EAAE,WAAW,EAAE;AAAA,IACjC,CAAC;AAED,QAAI,SAAS,SAAS,OAAO,SAAS,UAAU,IAAK;AAErD,UAAM,WAAW,SAAS,QAAQ,IAAI,UAAU;AAChD,QAAI,CAAC,SAAU;AACf,aAAS,mBAAmB,IAAI,IAAI,UAAU,MAAM,EAAE,SAAS,CAAC;AAChE,eAAW;AAAA,EACf;AAEA,MAAI,CAAC,SAAU,OAAM,IAAI,gBAAgB,qBAAqB;AAC9D,MAAI,CAAC,SAAS,IAAI;AACd,UAAM,IAAI,gBAAgB,mBAAmB,SAAS,MAAM,KAAK,SAAS,MAAM;AAAA,EACpF;AAEA,QAAM,OAAO,MAAM,WAAW,QAAQ;AACtC,MAAI;AACJ,MAAI;AACA,cAAU,KAAK,MAAM,IAAI;AAAA,EAC7B,QAAQ;AACJ,UAAM,IAAI,gBAAgB,4BAA4B;AAAA,EAC1D;AAEA,QAAM,WAAW,WAAW,SAAS,OAAO,IAAI;AAChD,QAAM,eAAe,MAAM,QAAQ,QAAQ,IACrC,WACA,aAAa,QAAQ,OAAO,aAAa,WACvC,CAAC,QAAQ,IACT;AACR,MAAI,CAAC,cAAc;AACf,UAAM,IAAI;AAAA,MACN,OAAO,OACD,IAAI,OAAO,IAAI,sCACf;AAAA,IACV;AAAA,EACJ;AAEA,QAAM,OAAO,aACR,MAAM,GAAG,QAAQ,EACjB;AAAA,IAAI,CAAC,QACF,QAAQ,QAAQ,OAAO,QAAQ,YAAY,CAAC,MAAM,QAAQ,GAAG,IACtD,MACD,EAAE,OAAO,IAAI;AAAA,EACvB;AAGJ,QAAM,OAAO,CAAC,GAAG,IAAI,IAAI,KAAK,QAAQ,CAAC,QAAQ,OAAO,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,KAAK;AACxE,SAAO,EAAE,MAAM,KAAK;AACxB;AAmBO,SAAS,cAAc,OAAe,SAAiC;AAC1E,SAAO,MAAM,QAAQ,mCAAmC,CAAC,GAAG,UAAkB;AAC1E,UAAM,CAAC,WAAW,GAAG,IAAI,IAAI,MAAM,MAAM,GAAG;AAC5C,UAAM,MAAM,KAAK,KAAK,GAAG;AACzB,QAAI,cAAc,QAAS,QAAO,QAAQ,MAAM,GAAG,KAAK;AACxD,QAAI,cAAc,SAAU,QAAO,QAAQ,OAAO,GAAG,KAAK;AAC1D,QAAI,cAAc,UAAU,QAAQ,OAAQ,QAAO,QAAQ,KAAK;AAChE,WAAO;AAAA,EACX,CAAC;AACL;AAGA,SAAS,WAAW,MAAmB,SAAyB;AAC5D,QAAM,OAAO,KAAK,IAAI,KAAK;AAC3B,MAAI,CAAC,gCAAgC,KAAK,IAAI,EAAG,QAAO;AAExD,QAAM,QAAQ,cAAc,KAAK,OAAO,OAAO,EAAE,QAAQ,aAAa,EAAE,EAAE,KAAK;AAC/E,SAAO,QAAS,CAAC,MAAM,KAAK,IAAc;AAC9C;AAMO,SAAS,aAAa,QAAoB,SAAyB;AACtE,QAAM,MAAM,mBAAmB,cAAc,OAAO,KAAK,OAAO,CAAC;AAEjE,aAAW,QAAQ,OAAO,UAAU,CAAC,GAAG;AACpC,UAAM,MAAM,KAAK,IAAI,KAAK;AAC1B,QAAI,CAAC,IAAK;AACV,UAAM,QAAQ,cAAc,KAAK,OAAO,OAAO;AAE/C,QAAI,UAAU,GAAI;AAClB,QAAI,aAAa,IAAI,KAAK,KAAK;AAAA,EACnC;AAEA,QAAM,SAAqB,OAAO,UAAU;AAE5C,QAAM,UAAkC;AAAA,IACpC,QAAQ;AAAA,EACZ;AAEA,MAAI;AACJ,MAAI,UAAU,MAAM,KAAK,OAAO,MAAM,KAAK,GAAG;AAC1C,WAAO,cAAc,OAAO,MAAM,OAAO;AACzC,YAAQ,cAAc,IAAI;AAAA,EAC9B;AAGA,aAAW,QAAQ,OAAO,WAAW,CAAC,GAAG;AACrC,UAAM,OAAO,WAAW,MAAM,OAAO;AACrC,QAAI,KAAM,SAAQ,KAAK,CAAC,CAAC,IAAI,KAAK,CAAC;AAAA,EACvC;AAGA,SAAO,EAAE,KAAK,mBAAmB,IAAI,SAAS,CAAC,GAAG,SAAS,QAAQ,KAAK;AAC5E;AAEO,IAAM,gBAAgC,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,GAAG,MAAM,EAAE,MAAM,GAAG,EAAE;;;AC/QlF,IAAM,wBAAN,cAAoC,MAAM;AAAA,EAC7C,YAA4B,UAAkB;AAC1C,UAAM,eAAe,QAAQ,gBAAgB;AADrB;AAExB,SAAK,OAAO;AAAA,EAChB;AACJ;AAOA,eAAsB,aAClB,UACA,SACA,SACiB;AACjB,QAAM,OAAO,IAAI;AAAA,IACb,SACK,OAAO,CAAC,OAAO,GAAG,QAAQ,EAC1B,IAAI,CAAC,OAAO,GAAG,QAAkB;AAAA,EAC1C;AACA,QAAM,SAAS,QAAQ,OAAO,CAAC,WAAW,KAAK,IAAI,OAAO,EAAE,KAAK,OAAO,GAAG;AAC3E,MAAI,OAAO,WAAW,EAAG,QAAO,CAAC;AAEjC,QAAM,UAAU,MAAM,QAAQ;AAAA,IAC1B,OAAO,IAAI,OAAO,WAAW;AACzB,UAAI;AACA,cAAM,EAAE,KAAK,IAAI,MAAM,WAAW,QAAQ,OAAO;AACjD,eAAO,CAAC,OAAO,IAAI,IAAI;AAAA,MAC3B,SAAS,OAAO;AACZ,YACI,iBAAiB,mBACjB,MAAM,WAAW,OACjB,OAAO,eAAe,YACxB;AACE,gBAAM,IAAI,sBAAsB,OAAO,EAAE;AAAA,QAC7C;AACA,gBAAQ;AAAA,UACJ,gBAAgB,OAAO,IAAI;AAAA,UAC3B,iBAAiB,kBAAkB,MAAM,UAAU;AAAA,QACvD;AACA,eAAO,CAAC,OAAO,IAAI,CAAC,CAAC;AAAA,MACzB;AAAA,IACJ,CAAC;AAAA,EACL;AAEA,SAAO,OAAO,YAAY,OAAO;AACrC;",
6
6
  "names": []
7
7
  }
@@ -4112,6 +4112,11 @@ function applySize(css, axis, mode, value, isMainAxis, parentAlign = "start") {
4112
4112
  if (isMainAxis) css.flexShrink = 0;
4113
4113
  return;
4114
4114
  }
4115
+ if (mode === "screen") {
4116
+ css[axis] = axis === "height" ? "100vh" : "100vw";
4117
+ if (isMainAxis) css.flexShrink = 0;
4118
+ return;
4119
+ }
4115
4120
  if (mode === "auto") {
4116
4121
  css[axis] = "auto";
4117
4122
  if (isMainAxis) css.flexGrow = 0;
@@ -4137,6 +4142,8 @@ function splitBand(css, contentWidth) {
4137
4142
  flexWrap: css.flexWrap,
4138
4143
  gridTemplateColumns: css.gridTemplateColumns,
4139
4144
  width: "100%",
4145
+ height: css.height,
4146
+ minHeight: css.minHeight,
4140
4147
  maxWidth: contentWidth,
4141
4148
  marginLeft: "auto",
4142
4149
  marginRight: "auto"
@@ -4571,6 +4578,10 @@ function interactionDeclarations(element, byId, breakpoint, rootStyle, cascade,
4571
4578
  }
4572
4579
  return declarationsToCss(delta);
4573
4580
  }
4581
+ function hoverSelector(element, byId) {
4582
+ const parent = element.hoverTrigger === "parent" && element.parentId ? byId.get(element.parentId) : void 0;
4583
+ return parent ? `.${classFor(parent.id)}:hover .${classFor(element.id)}` : `.${classFor(element.id)}:hover`;
4584
+ }
4574
4585
  var ENTRANCE_KEYFRAMES = `
4575
4586
  @keyframes pg-fade{from{opacity:0}to{opacity:1}}
4576
4587
  @keyframes pg-up{from{opacity:0;translate:0 28px}to{opacity:1;translate:none}}
@@ -4684,7 +4695,7 @@ function stylesheetFor(elements, rootStyle) {
4684
4695
  }
4685
4696
  for (const element of elements) {
4686
4697
  if (element.hover || element.press) parts.push(`.${classFor(element.id)}{transition:transform .42s cubic-bezier(.16,1,.3,1),scale .42s cubic-bezier(.16,1,.3,1),rotate .42s cubic-bezier(.16,1,.3,1),translate .42s cubic-bezier(.16,1,.3,1),background-color .32s ease,color .32s ease,border-color .32s ease,box-shadow .42s cubic-bezier(.16,1,.3,1),opacity .32s ease,filter .42s ease;will-change:transform}`);
4687
- if (element.hover && Object.keys(element.hover).length) parts.push(`.${classFor(element.id)}:hover{${interactionDeclarations(element, byId, baseId, rootStyle, cascade, element.hover)}}`);
4698
+ if (element.hover && Object.keys(element.hover).length) parts.push(`${hoverSelector(element, byId)}{${interactionDeclarations(element, byId, baseId, rootStyle, cascade, element.hover)}}`);
4688
4699
  if (element.press && Object.keys(element.press).length) parts.push(`.${classFor(element.id)}:active{${interactionDeclarations(element, byId, baseId, rootStyle, cascade, element.press)}}`);
4689
4700
  if (element.loop) {
4690
4701
  const name = element.loop.type;
@@ -4698,7 +4709,7 @@ function stylesheetFor(elements, rootStyle) {
4698
4709
  if (!element.overrides?.[plan.id] && !parent?.overrides?.[plan.id]) return [];
4699
4710
  const selector = `.${classFor(element.id)}`;
4700
4711
  return [
4701
- element.hover && Object.keys(element.hover).length ? `${selector}:hover{${interactionDeclarations(element, byId, plan.id, rootStyle, cascade, element.hover)}}` : "",
4712
+ element.hover && Object.keys(element.hover).length ? `${hoverSelector(element, byId)}{${interactionDeclarations(element, byId, plan.id, rootStyle, cascade, element.hover)}}` : "",
4702
4713
  element.press && Object.keys(element.press).length ? `${selector}:active{${interactionDeclarations(element, byId, plan.id, rootStyle, cascade, element.press)}}` : ""
4703
4714
  ].filter(Boolean);
4704
4715
  }).join("");
@@ -5131,6 +5142,10 @@ var SIZE_OPTIONS = [
5131
5142
  { label: "Fill", value: "fill" },
5132
5143
  { label: "Hug", value: "auto" }
5133
5144
  ];
5145
+ var HEIGHT_SIZE_OPTIONS = [
5146
+ ...SIZE_OPTIONS,
5147
+ { label: "Screen", value: "screen" }
5148
+ ];
5134
5149
  function SizeField({
5135
5150
  axis,
5136
5151
  mode,
@@ -5164,13 +5179,13 @@ function SizeField({
5164
5179
  /* @__PURE__ */ jsx3("span", { className: "shrink-0 text-ed-faint", children: "px" })
5165
5180
  ] }) : (
5166
5181
  // The layout owns this axis, so there is no number to type.
5167
- /* @__PURE__ */ jsx3("span", { className: "flex-1 text-right text-ed-faint", children: mode === "fill" ? "Fill" : "Hug" })
5182
+ /* @__PURE__ */ jsx3("span", { className: "flex-1 text-right text-ed-faint", children: mode === "fill" ? "Fill" : mode === "screen" ? "100vh" : "Hug" })
5168
5183
  ) }),
5169
5184
  /* @__PURE__ */ jsx3(
5170
5185
  SelectShell,
5171
5186
  {
5172
5187
  value: mode,
5173
- options: SIZE_OPTIONS,
5188
+ options: axis === "H" ? HEIGHT_SIZE_OPTIONS : SIZE_OPTIONS,
5174
5189
  onChange: onMode,
5175
5190
  ariaLabel: `${name} sizing mode`,
5176
5191
  className: "w-[76px] shrink-0"
@@ -5466,6 +5481,7 @@ function DesignTab(ctx) {
5466
5481
  /* @__PURE__ */ jsx4(SizeGroup, { ctx, ov }),
5467
5482
  isContainer(element.type) && /* @__PURE__ */ jsx4(LayoutGroup, { ctx, ov }),
5468
5483
  textual && /* @__PURE__ */ jsx4(TypographyGroup, { ctx, ov }),
5484
+ element.type === "Icon" && /* @__PURE__ */ jsx4(IconAppearanceGroup, { ctx, ov }),
5469
5485
  /* @__PURE__ */ jsx4(FillGroup, { ctx, ov, defaultOpen: !textual }),
5470
5486
  /* @__PURE__ */ jsx4(SpacingGroup, { ctx, ov }),
5471
5487
  /* @__PURE__ */ jsx4(EffectsGroup, { ctx, ov }),
@@ -5473,6 +5489,20 @@ function DesignTab(ctx) {
5473
5489
  /* @__PURE__ */ jsx4(MotionGroup, { ctx, ov })
5474
5490
  ] });
5475
5491
  }
5492
+ function IconAppearanceGroup({ ctx, ov }) {
5493
+ const { style, onStyle } = ctx;
5494
+ return /* @__PURE__ */ jsx4(Group, { title: "Icon", children: ov(
5495
+ ["color"],
5496
+ /* @__PURE__ */ jsx4(
5497
+ ColorInput,
5498
+ {
5499
+ label: "Colour",
5500
+ value: style.color,
5501
+ onChange: (color) => onStyle({ color })
5502
+ }
5503
+ )
5504
+ ) });
5505
+ }
5476
5506
  function SizeGroup({ ctx, ov }) {
5477
5507
  const { style, parentLayout, onStyle, onCommitStart, onCommitEnd } = ctx;
5478
5508
  return /* @__PURE__ */ jsxs3(Group, { title: "Size", children: [
@@ -6791,6 +6821,18 @@ function HoverTab({ element, style, onProps, onStyle }) {
6791
6821
  }
6792
6822
  ) : null,
6793
6823
  children: [
6824
+ element.parentId && /* @__PURE__ */ jsx4(
6825
+ Segmented,
6826
+ {
6827
+ label: "Trigger",
6828
+ value: element.hoverTrigger ?? "self",
6829
+ options: [
6830
+ { label: "Self", value: "self" },
6831
+ { label: "Parent", value: "parent" }
6832
+ ],
6833
+ onChange: (hoverTrigger) => onProps({ hoverTrigger })
6834
+ }
6835
+ ),
6794
6836
  /* @__PURE__ */ jsx4(
6795
6837
  ColorInput,
6796
6838
  {
@@ -11262,6 +11304,7 @@ function Editor({
11262
11304
  const [hoveredEffectIds, setHoveredEffectIds] = useState14(() => /* @__PURE__ */ new Set());
11263
11305
  const [pressedEffectId, setPressedEffectId] = useState14(null);
11264
11306
  const [effectsPreview, setEffectsPreview] = useState14(false);
11307
+ const [stickyPreview, setStickyPreview] = useState14(true);
11265
11308
  const [previewVisibility, setPreviewVisibility] = useState14({});
11266
11309
  const [marquee, setMarquee] = useState14(null);
11267
11310
  const [codeComposerOpen, setCodeComposerOpen] = useState14(false);
@@ -12520,6 +12563,13 @@ function Editor({
12520
12563
  const band = isBand(el.type, style, rootStyle);
12521
12564
  const css = styleToCss(style, contextFor(el, frame.bp), el);
12522
12565
  if (css.position === "fixed") css.position = "absolute";
12566
+ if (!stickyPreview && style.position === "sticky") {
12567
+ css.position = "relative";
12568
+ css.top = void 0;
12569
+ css.right = void 0;
12570
+ css.bottom = void 0;
12571
+ css.left = void 0;
12572
+ }
12523
12573
  if (el.hover || el.press) css.transition = "transform .42s cubic-bezier(.16,1,.3,1), scale .42s cubic-bezier(.16,1,.3,1), rotate .42s cubic-bezier(.16,1,.3,1), background-color .32s ease, color .32s ease, border-color .32s ease, box-shadow .42s cubic-bezier(.16,1,.3,1), opacity .32s ease, filter .42s ease";
12524
12574
  if (el.loop) css.animation = `pg-loop-${el.loop.type} ${el.loop.duration}ms ease-in-out infinite`;
12525
12575
  css.cursor = el.locked ? "default" : isEditing ? "text" : "move";
@@ -12542,6 +12592,10 @@ function Editor({
12542
12592
  el.type === "Request" ? canvasData[el.sourceId ?? ""]?.[0] : row,
12543
12593
  keyPrefix
12544
12594
  ));
12595
+ const hoverEffectIds = [
12596
+ ...el.hover && el.hoverTrigger !== "parent" ? [el.id] : [],
12597
+ ...children.filter((child) => child.hover && child.hoverTrigger === "parent").map((child) => child.id)
12598
+ ];
12545
12599
  return (
12546
12600
  // A canvas node is manipulated by pointer; the Layers panel is its keyboard equivalent.
12547
12601
  // biome-ignore lint/a11y/noStaticElementInteractions: pointer-driven canvas node
@@ -12551,18 +12605,18 @@ function Editor({
12551
12605
  "data-canvas-element": el.id,
12552
12606
  style: split ? split.shell : css,
12553
12607
  onMouseDown: (event) => handleElementMouseDown(event, interactionElement, frame.bp),
12554
- onMouseEnter: () => effectsPreview && el.hover && setHoveredEffectIds((current) => {
12555
- if (current.has(el.id)) return current;
12608
+ onMouseEnter: () => effectsPreview && hoverEffectIds.length > 0 && setHoveredEffectIds((current) => {
12609
+ if (hoverEffectIds.every((id) => current.has(id))) return current;
12556
12610
  const next = new Set(current);
12557
- next.add(el.id);
12611
+ for (const id of hoverEffectIds) next.add(id);
12558
12612
  return next;
12559
12613
  }),
12560
12614
  onMouseUp: () => effectsPreview && setPressedEffectId((id) => id === el.id ? null : id),
12561
12615
  onMouseLeave: () => {
12562
12616
  setHoveredEffectIds((current) => {
12563
- if (!current.has(el.id)) return current;
12617
+ if (!hoverEffectIds.some((id) => current.has(id))) return current;
12564
12618
  const next = new Set(current);
12565
- next.delete(el.id);
12619
+ for (const id of hoverEffectIds) next.delete(id);
12566
12620
  return next;
12567
12621
  });
12568
12622
  setPressedEffectId((id) => id === el.id ? null : id);
@@ -12626,7 +12680,7 @@ function Editor({
12626
12680
  Math.round(style.widthMode === "fixed" ? style.w : 0) || "auto",
12627
12681
  " \xD7",
12628
12682
  " ",
12629
- Math.round(style.heightMode === "fixed" ? style.h : 0) || "auto"
12683
+ style.heightMode === "screen" ? "100vh" : Math.round(style.heightMode === "fixed" ? style.h : 0) || "auto"
12630
12684
  ]
12631
12685
  }
12632
12686
  ),
@@ -13446,6 +13500,17 @@ function Editor({
13446
13500
  setPressedEffectId(null);
13447
13501
  setPreviewVisibility({});
13448
13502
  }, className: `rounded-md p-1.5 transition-colors ${effectsPreview ? "bg-ed-accent text-white" : "text-ed-faint hover:bg-ed-field hover:text-ed-text"}`, children: /* @__PURE__ */ jsx15(IconPlayerPlay5, { size: 14, stroke: 1.5 }) }),
13503
+ /* @__PURE__ */ jsx15(
13504
+ "button",
13505
+ {
13506
+ type: "button",
13507
+ "aria-pressed": stickyPreview,
13508
+ title: stickyPreview ? "Disable sticky positioning on the canvas" : "Preview sticky positioning on the canvas",
13509
+ onClick: () => setStickyPreview((value) => !value),
13510
+ className: `rounded-md p-1.5 transition-colors ${stickyPreview ? "bg-ed-accent text-white" : "text-ed-faint hover:bg-ed-field hover:text-ed-text"}`,
13511
+ children: stickyPreview ? /* @__PURE__ */ jsx15(IconPinFilled, { size: 14, stroke: 1.5 }) : /* @__PURE__ */ jsx15(IconPin, { size: 14, stroke: 1.5 })
13512
+ }
13513
+ ),
13449
13514
  /* @__PURE__ */ jsx15(
13450
13515
  "button",
13451
13516
  {