Agents (llms.txt)

Menu builder

Primitives for the QR menu editor — themed canvas preview, layers outline, morphing toolbar, and inspector panels that share the same container-agnostic panel content.

Harbour Room
Draft

Starters

Mains

import { MenuBuilderEditor } from '@/components/demos/menu-builder-editor';

export const meta = {
  layout: 'fullscreen',
} as const;

export default function MenuBuilderEditorPreview() {
  return <MenuBuilderEditor />;
}

Dependencies

Source Code

/** Layout variant for items inside a section. */
type MenuItemLayout = 'list' | 'cards' | 'grid' | 'editorial';

/** Theme tokens for the menu being designed (not app chrome). */
interface MenuTheme {
  background: string;
  textColor: string;
  accentColor: string;
  mutedColor: string;
  dividerColor: string;
  radius: string;
  headingFamily: string;
  headingWeight: number;
  headingTracking: string;
  headingTransform?: string;
  bodyFamily: string;
  bodyWeight: number;
  bodyTracking: string;
  priceFamily: string;
  priceWeight: number;
  priceTracking: string;
  itemLayout: MenuItemLayout;
  sectionGap: string;
  itemGap: string;
  itemPadY: string;
}

interface MenuItemData {
  id: string;
  name: string;
  description?: string;
  price?: string;
  dietary?: string[];
  imageUrl?: string;
}

interface MenuSectionData {
  id: string;
  title: string;
  items: MenuItemData[];
}

/** Top-level editor mode (Presets · Design · Menu). */
type EditorMode = 'presets' | 'design' | 'menu';

type EditorSelection =
  | { kind: 'none' }
  | { kind: 'brand' }
  | { kind: 'section'; id: string }
  | { kind: 'item'; sectionId: string; itemId: string };

/** Maps editor selection to a toolbar / inspector panel slot. */
type MenuEditorPanel =
  | 'brand'
  | 'typography'
  | 'layout'
  | 'background'
  | 'section'
  | 'item';

const defaultMenuTheme: MenuTheme = {
  background: 'oklch(0.98 0.01 95)',
  textColor: 'oklch(0.22 0.02 50)',
  accentColor: 'oklch(0.55 0.14 45)',
  mutedColor: 'oklch(0.55 0.02 50)',
  dividerColor: 'oklch(0.88 0.01 95)',
  radius: '1rem',
  headingFamily: 'var(--font-sans)',
  headingWeight: 600,
  headingTracking: '0.02em',
  headingTransform: 'none',
  bodyFamily: 'var(--font-sans)',
  bodyWeight: 400,
  bodyTracking: '0',
  priceFamily: 'var(--font-sans)',
  priceWeight: 500,
  priceTracking: '0',
  itemLayout: 'list',
  sectionGap: '2rem',
  itemGap: '0.75rem',
  itemPadY: '0.75rem',
};

const sampleMenuSections: MenuSectionData[] = [
  {
    id: 'starters',
    title: 'Starters',
    items: [
      {
        id: 'oysters',
        name: 'Oysters natural (6)',
        description: 'Lemon, mignonette',
        price: '$24',
        dietary: ['GF'],
      },
      {
        id: 'bread',
        name: 'Sourdough & cultured butter',
        price: '$12',
      },
    ],
  },
  {
    id: 'mains',
    title: 'Mains',
    items: [
      {
        id: 'fish',
        name: 'Grilled barramundi',
        description: 'Fennel, capers, olive oil',
        price: '$38',
        dietary: ['GF'],
      },
      {
        id: 'pasta',
        name: 'Hand-cut pappardelle',
        description: 'Wild mushroom, parmesan',
        price: '$32',
        dietary: ['V'],
      },
    ],
  },
];

function selectionToPanel(selection: EditorSelection): MenuEditorPanel | null {
  switch (selection.kind) {
    case 'brand':
      return 'brand';
    case 'section':
      return 'section';
    case 'item':
      return 'item';
    case 'none':
      return null;
  }
}

function isSelectionActive(
  selection: EditorSelection,
  target: EditorSelection
): boolean {
  if (selection.kind !== target.kind) return false;
  if (selection.kind === 'none' || target.kind === 'none') {
    return selection.kind === 'none' && target.kind === 'none';
  }
  if (selection.kind === 'brand' && target.kind === 'brand') return true;
  if (selection.kind === 'section' && target.kind === 'section') {
    return selection.id === target.id;
  }
  if (selection.kind === 'item' && target.kind === 'item') {
    return (
      selection.sectionId === target.sectionId &&
      selection.itemId === target.itemId
    );
  }
  return false;
}

export type {
  EditorMode,
  EditorSelection,
  MenuEditorPanel,
  MenuItemData,
  MenuItemLayout,
  MenuSectionData,
  MenuTheme,
};
export {
  defaultMenuTheme,
  isSelectionActive,
  sampleMenuSections,
  selectionToPanel,
};
'use client';

import { useMemo } from 'react';
import { menuThemeToStyle } from '@/components/menu-builder/menu-theme-style';
import type { MenuTheme } from '@/components/menu-builder/types';
import { cn } from '@/lib/utils/classnames';

interface MenuThemeProviderProps extends React.ComponentPropsWithRef<'div'> {
  theme: MenuTheme;
}

/**
 * Projects a {@link MenuTheme} onto `--menu-*` CSS variables for a scoped subtree.
 * Menu canvas, sections, and items read variables — never theme props.
 */
const MenuThemeProvider = ({
  theme,
  className,
  style,
  ref,
  children,
  ...props
}: MenuThemeProviderProps) => {
  const themeStyle = useMemo(() => menuThemeToStyle(theme), [theme]);

  return (
    <div
      ref={ref}
      data-menu-theme=""
      style={{ ...themeStyle, ...style }}
      className={cn(
        'bg-(--menu-bg) text-(--menu-text) [font-family:var(--menu-body-family)] [font-weight:var(--menu-body-weight)] [letter-spacing:var(--menu-body-tracking)]',
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
};

export type { MenuThemeProviderProps };
export { MenuThemeProvider };
'use client';

import { cn } from '@/lib/utils/classnames';

interface MenuBuilderLayoutProps extends React.ComponentPropsWithRef<'div'> {}

/** Full-viewport editor shell: header, mode bar, preview stage, control column. */
const MenuBuilderLayout = ({
  className,
  children,
  ref,
  ...props
}: MenuBuilderLayoutProps) => (
  <div
    ref={ref}
    className={cn(
      'flex h-full min-h-0 flex-col overflow-hidden bg-background',
      className
    )}
    {...props}
  >
    {children}
  </div>
);

interface MenuBuilderLayoutHeaderProps
  extends React.ComponentPropsWithRef<'header'> {}

const MenuBuilderLayoutHeader = ({
  className,
  children,
  ref,
  ...props
}: MenuBuilderLayoutHeaderProps) => (
  <header
    ref={ref}
    className={cn(
      'flex shrink-0 items-center gap-3 border-border border-b bg-background px-4 py-3',
      className
    )}
    {...props}
  >
    {children}
  </header>
);

interface MenuBuilderLayoutModesProps
  extends React.ComponentPropsWithRef<'div'> {}

const MenuBuilderLayoutModes = ({
  className,
  children,
  ref,
  ...props
}: MenuBuilderLayoutModesProps) => (
  <div
    ref={ref}
    className={cn(
      'flex shrink-0 items-center border-border border-b bg-background px-4 py-2.5',
      className
    )}
    {...props}
  >
    {children}
  </div>
);

interface MenuBuilderLayoutWorkspaceProps
  extends React.ComponentPropsWithRef<'div'> {}

/** Preview stage + control column. Column on `md+`, stacked on small screens. */
const MenuBuilderLayoutWorkspace = ({
  className,
  children,
  ref,
  ...props
}: MenuBuilderLayoutWorkspaceProps) => (
  <div
    ref={ref}
    className={cn('flex min-h-0 flex-1 flex-col md:flex-row', className)}
    {...props}
  >
    {children}
  </div>
);

interface MenuBuilderLayoutStageProps
  extends React.ComponentPropsWithRef<'div'> {}

/**
 * Centered preview stage for {@link MenuCanvas}. Fills remaining width/height;
 * warm secondary surface so the phone frame reads as the focal point.
 */
const MenuBuilderLayoutStage = ({
  className,
  children,
  ref,
  ...props
}: MenuBuilderLayoutStageProps) => (
  <div
    ref={ref}
    className={cn(
      'relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden',
      'bg-background-secondary',
      className
    )}
    {...props}
  >
    <div
      aria-hidden
      className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,var(--color-background)_0%,transparent_70%)] opacity-80"
    />
    <div className="relative flex min-h-0 flex-1 flex-col">{children}</div>
  </div>
);

interface MenuBuilderLayoutControlsProps
  extends React.ComponentPropsWithRef<'aside'> {}

/** Right control column (`md+`) or below preview on small screens. */
const MenuBuilderLayoutControls = ({
  className,
  children,
  ref,
  ...props
}: MenuBuilderLayoutControlsProps) => (
  <aside
    ref={ref}
    className={cn(
      'flex min-h-0 w-full shrink-0 flex-col border-border bg-background',
      'max-md:border-t md:w-80 md:border-t-0 md:border-l',
      'max-md:max-h-[min(40vh,320px)]',
      className
    )}
    {...props}
  >
    {children}
  </aside>
);

const CompoundMenuBuilderLayout = Object.assign(MenuBuilderLayout, {
  Header: MenuBuilderLayoutHeader,
  Modes: MenuBuilderLayoutModes,
  Workspace: MenuBuilderLayoutWorkspace,
  Stage: MenuBuilderLayoutStage,
  Controls: MenuBuilderLayoutControls,
});

export type {
  MenuBuilderLayoutControlsProps,
  MenuBuilderLayoutHeaderProps,
  MenuBuilderLayoutModesProps,
  MenuBuilderLayoutProps,
  MenuBuilderLayoutStageProps,
  MenuBuilderLayoutWorkspaceProps,
};
export { CompoundMenuBuilderLayout as MenuBuilderLayout };
'use client';

import { useCallback, useRef } from 'react';
import { MenuSectionView } from '@/components/menu-builder/menu-section-view';
import { MenuThemeProvider } from '@/components/menu-builder/menu-theme-provider';
import type {
  EditorSelection,
  MenuItemLayout,
  MenuSectionData,
  MenuTheme,
} from '@/components/menu-builder/types';
import { cn } from '@/lib/utils/classnames';

interface MenuCanvasProps extends React.ComponentPropsWithRef<'div'> {
  theme: MenuTheme;
  sections: MenuSectionData[];
  selection: EditorSelection;
  onSelectionChange?: (selection: EditorSelection) => void;
  /** Zoom scale for the phone frame. Default `1`. */
  zoom?: number;
  layout?: MenuItemLayout;
}

/**
 * Phone-frame stage for the live menu preview. Clicking empty canvas resets
 * selection to `{ kind: 'none' }`.
 */
const MenuCanvas = ({
  theme,
  sections,
  selection,
  onSelectionChange,
  zoom = 1,
  layout,
  className,
  ref,
  ...props
}: MenuCanvasProps) => {
  const frameRef = useRef<HTMLDivElement>(null);
  const itemLayout = layout ?? theme.itemLayout;

  const handleCanvasPointerDown = useCallback(
    (e: React.PointerEvent<HTMLDivElement>) => {
      if (!onSelectionChange) return;
      const target = e.target as Node;
      if (frameRef.current?.contains(target) && target !== frameRef.current) {
        return;
      }
      onSelectionChange({ kind: 'none' });
    },
    [onSelectionChange]
  );

  const handleFrameBackgroundClick = useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      if (e.target === e.currentTarget) {
        onSelectionChange?.({ kind: 'none' });
      }
    },
    [onSelectionChange]
  );

  return (
    <div
      ref={ref}
      className={cn(
        'flex h-full min-h-0 w-full items-center justify-center p-4 sm:p-6 md:p-8',
        className
      )}
      onPointerDown={handleCanvasPointerDown}
      {...props}
    >
      <div
        className="flex h-full max-h-full w-full max-w-[min(100%,390px)] flex-col justify-center"
        style={{
          transform: `scale(${zoom})`,
          transformOrigin: 'center center',
        }}
      >
        <div
          className={cn(
            'flex w-full min-h-[420px] flex-col overflow-hidden rounded-[2rem] border border-border bg-background shadow-(--shadow-raised)'
          )}
        >
          <div className="flex shrink-0 justify-center border-border border-b bg-foreground/3 py-2">
            <div className="h-1 w-16 rounded-full bg-foreground/15" />
          </div>

          <div
            ref={frameRef}
            className="max-h-[min(72svh,720px)] min-h-0 flex-1 overflow-y-auto overscroll-contain md:max-h-[min(calc(100svh-12rem),720px)]"
          >
            <MenuThemeProvider
              theme={theme}
              className="min-h-[480px] p-5"
              onClick={handleFrameBackgroundClick}
            >
              {sections.map((section) => (
                <MenuSectionView
                  key={section.id}
                  section={section}
                  layout={itemLayout}
                  selection={selection}
                  onSelectSection={(id) =>
                    onSelectionChange?.({ kind: 'section', id })
                  }
                  onSelectItem={(sectionId, itemId) =>
                    onSelectionChange?.({
                      kind: 'item',
                      sectionId,
                      itemId,
                    })
                  }
                />
              ))}
              <button
                type="button"
                className="mt-4 w-full rounded-(--menu-radius) border border-(--menu-divider) border-dashed py-3 text-(--menu-muted) text-xs"
                onClick={(e) => {
                  e.stopPropagation();
                  onSelectionChange?.({ kind: 'brand' });
                }}
              >
                Tap to edit brand & theme
              </button>
            </MenuThemeProvider>
          </div>
        </div>
      </div>
    </div>
  );
};

export type { MenuCanvasProps };
export { MenuCanvas };
'use client';

import { MenuItemView } from '@/components/menu-builder/menu-item-view';
import type {
  EditorSelection,
  MenuItemData,
  MenuItemLayout,
  MenuSectionData,
} from '@/components/menu-builder/types';
import { cn } from '@/lib/utils/classnames';

interface MenuSectionViewProps extends React.ComponentPropsWithRef<'section'> {
  section: MenuSectionData;
  layout: MenuItemLayout;
  selection: EditorSelection;
  onSelectSection?: (sectionId: string) => void;
  onSelectItem?: (sectionId: string, itemId: string) => void;
}

const itemGridClass: Record<MenuItemLayout, string> = {
  list: 'flex flex-col',
  cards: 'grid grid-cols-1 gap-(--menu-item-gap) sm:grid-cols-2',
  grid: 'grid grid-cols-2 gap-(--menu-item-gap)',
  editorial: 'flex flex-col',
};

const MenuSectionView = ({
  section,
  layout,
  selection,
  onSelectSection,
  onSelectItem,
  className,
  ref,
  onClick,
  ...props
}: MenuSectionViewProps) => {
  const sectionSelected =
    selection.kind === 'section' && selection.id === section.id;

  const handleSectionClick = (e: React.MouseEvent<HTMLElement>) => {
    onClick?.(e);
    if (!e.defaultPrevented) {
      e.stopPropagation();
      onSelectSection?.(section.id);
    }
  };

  const handleSectionKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
    if (!onSelectSection) return;
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      onSelectSection(section.id);
    }
  };

  return (
    // biome-ignore lint/a11y/noStaticElementInteractions: editor block; role=button when selectable
    <section
      ref={ref}
      data-selected={sectionSelected || undefined}
      role={onSelectSection ? 'button' : undefined}
      tabIndex={onSelectSection ? 0 : undefined}
      onClick={onSelectSection ? handleSectionClick : onClick}
      onKeyDown={onSelectSection ? handleSectionKeyDown : undefined}
      className={cn(
        'flex flex-col gap-3',
        onSelectSection &&
          'focus-within:ring-(length:--ring-width) cursor-pointer rounded-[calc(var(--menu-radius)*0.5)] outline-none ring-ring',
        sectionSelected &&
          'ring-(--menu-accent) ring-2 ring-offset-(--menu-bg) ring-offset-2',
        className
      )}
      style={{ marginBottom: 'var(--menu-section-gap)' }}
      {...props}
    >
      <h2
        className={cn(
          'font-[family-name:var(--menu-heading-family)] font-[number:var(--menu-heading-weight)] text-(--menu-text) text-base tracking-[var(--menu-heading-tracking)] [text-transform:var(--menu-heading-transform)]'
        )}
      >
        {section.title}
      </h2>
      <div className={itemGridClass[layout]}>
        {section.items.map((item: MenuItemData) => (
          <MenuItemView
            key={item.id}
            item={item}
            layout={layout}
            selected={
              selection.kind === 'item' &&
              selection.sectionId === section.id &&
              selection.itemId === item.id
            }
            onSelect={
              onSelectItem
                ? () => {
                    onSelectItem(section.id, item.id);
                  }
                : undefined
            }
          />
        ))}
      </div>
    </section>
  );
};

export type { MenuSectionViewProps };
export { MenuSectionView };
'use client';

import type {
  MenuItemData,
  MenuItemLayout,
} from '@/components/menu-builder/types';
import { cn } from '@/lib/utils/classnames';

interface MenuItemViewProps extends React.ComponentPropsWithRef<'div'> {
  item: MenuItemData;
  layout: MenuItemLayout;
  selected?: boolean;
  onSelect?: () => void;
}

const dietaryClass =
  'rounded-full border border-(--menu-divider) px-1.5 py-px text-[10px] text-(--menu-muted)';

const MenuItemView = ({
  item,
  layout,
  selected,
  onSelect,
  className,
  ref,
  onClick,
  ...props
}: MenuItemViewProps) => {
  const interactive = Boolean(onSelect);

  const handleButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (!e.defaultPrevented) onSelect?.();
  };

  const content = (() => {
    switch (layout) {
      case 'cards':
        return (
          <article className="flex flex-col gap-2 rounded-(--menu-radius) border border-(--menu-divider) p-3">
            {item.imageUrl ? (
              <div
                className="aspect-[4/3] w-full rounded-[calc(var(--menu-radius)*0.75)] bg-(--menu-muted)/15"
                style={
                  item.imageUrl
                    ? {
                        backgroundImage: `url(${item.imageUrl})`,
                        backgroundSize: 'cover',
                      }
                    : undefined
                }
              />
            ) : null}
            <div className="flex items-start justify-between gap-2">
              <h3 className="font-[family-name:var(--menu-body-family)] font-[number:var(--menu-body-weight)] text-(--menu-text) text-sm leading-snug">
                {item.name}
              </h3>
              {item.price ? (
                <span className="shrink-0 font-[family-name:var(--menu-price-family)] font-[number:var(--menu-price-weight)] text-(--menu-accent) text-sm tabular-nums tracking-[var(--menu-price-tracking)]">
                  {item.price}
                </span>
              ) : null}
            </div>
            {item.description ? (
              <p className="text-(--menu-muted) text-xs leading-relaxed">
                {item.description}
              </p>
            ) : null}
            {item.dietary?.length ? (
              <div className="flex flex-wrap gap-1">
                {item.dietary.map((tag) => (
                  <span key={tag} className={dietaryClass}>
                    {tag}
                  </span>
                ))}
              </div>
            ) : null}
          </article>
        );

      case 'grid':
        return (
          <article className="flex flex-col gap-1 py-(--menu-item-pad-y)">
            <div className="flex items-baseline justify-between gap-2">
              <h3 className="font-[family-name:var(--menu-body-family)] text-(--menu-text) text-sm">
                {item.name}
              </h3>
              {item.price ? (
                <span className="font-[family-name:var(--menu-price-family)] font-[number:var(--menu-price-weight)] text-(--menu-accent) text-sm tabular-nums">
                  {item.price}
                </span>
              ) : null}
            </div>
            {item.description ? (
              <p className="text-(--menu-muted) text-xs">{item.description}</p>
            ) : null}
          </article>
        );

      case 'editorial':
        return (
          <article className="flex flex-col gap-2 border-(--menu-divider) border-b py-(--menu-item-pad-y) last:border-b-0">
            <h3 className="font-[family-name:var(--menu-heading-family)] font-[number:var(--menu-heading-weight)] text-(--menu-text) text-lg tracking-[var(--menu-heading-tracking)] [text-transform:var(--menu-heading-transform)]">
              {item.name}
            </h3>
            {item.description ? (
              <p className="max-w-prose text-(--menu-muted) text-sm leading-relaxed">
                {item.description}
              </p>
            ) : null}
            <div className="flex flex-wrap items-center gap-2">
              {item.price ? (
                <span className="font-[family-name:var(--menu-price-family)] text-(--menu-accent) text-base tabular-nums">
                  {item.price}
                </span>
              ) : null}
              {item.dietary?.map((tag) => (
                <span key={tag} className={dietaryClass}>
                  {tag}
                </span>
              ))}
            </div>
          </article>
        );

      default:
        return (
          <article className="flex gap-3 py-(--menu-item-pad-y)">
            <div className="min-w-0 flex-1">
              <div className="flex items-baseline justify-between gap-2">
                <h3 className="font-[family-name:var(--menu-body-family)] text-(--menu-text) text-sm">
                  {item.name}
                </h3>
                {item.price ? (
                  <span className="shrink-0 font-[family-name:var(--menu-price-family)] font-[number:var(--menu-price-weight)] text-(--menu-accent) text-sm tabular-nums">
                    {item.price}
                  </span>
                ) : null}
              </div>
              {item.description ? (
                <p className="mt-0.5 text-(--menu-muted) text-xs leading-relaxed">
                  {item.description}
                </p>
              ) : null}
              {item.dietary?.length ? (
                <div className="mt-1.5 flex flex-wrap gap-1">
                  {item.dietary.map((tag) => (
                    <span key={tag} className={dietaryClass}>
                      {tag}
                    </span>
                  ))}
                </div>
              ) : null}
            </div>
          </article>
        );
    }
  })();

  const ring = (
    <div
      className={cn(
        'rounded-[calc(var(--menu-radius)*0.5)] transition-shadow duration-(--duration-hover) ease-(--ease)',
        selected &&
          'ring-(--menu-accent) ring-2 ring-offset-(--menu-bg) ring-offset-2'
      )}
    >
      {content}
    </div>
  );

  if (interactive) {
    return (
      <button
        ref={ref as React.Ref<HTMLButtonElement>}
        type="button"
        data-selected={selected || undefined}
        onClick={handleButtonClick}
        className={cn(
          'focus-visible:ring-(length:--ring-width) w-full cursor-pointer rounded-[calc(var(--menu-radius)*0.5)] text-left outline-none ring-ring',
          className
        )}
      >
        {ring}
      </button>
    );
  }

  return (
    <div
      ref={ref}
      data-selected={selected || undefined}
      onClick={onClick}
      className={className}
      {...props}
    >
      {ring}
    </div>
  );
};

export type { MenuItemViewProps };
export { MenuItemView };
'use client';

import { ChevronRight, Palette } from '@untitledui-pro/icons/solid';
import { ListItem } from '@/components/list-item';
import type {
  EditorSelection,
  MenuSectionData,
} from '@/components/menu-builder/types';
import { isSelectionActive } from '@/components/menu-builder/types';
import { cn } from '@/lib/utils/classnames';

interface SectionTreeProps extends React.ComponentPropsWithRef<'nav'> {
  sections: MenuSectionData[];
  selection: EditorSelection;
  onSelectionChange?: (selection: EditorSelection) => void;
  venueName?: string;
}

const SectionTree = ({
  sections,
  selection,
  onSelectionChange,
  venueName = 'Brand',
  className,
  ref,
  ...props
}: SectionTreeProps) => {
  const select = (next: EditorSelection) => {
    onSelectionChange?.(next);
  };

  return (
    <nav
      ref={ref}
      aria-label="Menu outline"
      className={cn('flex flex-col gap-1', className)}
      {...props}
    >
      <ListItem
        asChild
        interactive
        size="sm"
        active={isSelectionActive(selection, { kind: 'brand' })}
      >
        <button type="button" onClick={() => select({ kind: 'brand' })}>
          <ListItem.Leading>
            <Palette className="size-4" aria-hidden />
          </ListItem.Leading>
          <ListItem.Content>
            <ListItem.Title>{venueName}</ListItem.Title>
            <ListItem.Subtitle>Colours & typography</ListItem.Subtitle>
          </ListItem.Content>
        </button>
      </ListItem>

      {sections.map((section) => {
        const sectionActive = isSelectionActive(selection, {
          kind: 'section',
          id: section.id,
        });

        return (
          <div key={section.id} className="flex flex-col gap-0.5">
            <ListItem asChild interactive size="sm" active={sectionActive}>
              <button
                type="button"
                onClick={() => select({ kind: 'section', id: section.id })}
              >
                <ListItem.Leading>
                  <ChevronRight
                    className={cn(
                      'size-4 transition-transform duration-(--duration-hover) ease-(--ease)',
                      sectionActive && 'rotate-90'
                    )}
                    aria-hidden
                  />
                </ListItem.Leading>
                <ListItem.Content>
                  <ListItem.Title>{section.title}</ListItem.Title>
                </ListItem.Content>
              </button>
            </ListItem>

            {(sectionActive || selection.kind === 'item') &&
              section.items.map((item) => (
                <ListItem
                  key={item.id}
                  asChild
                  interactive
                  size="sm"
                  active={isSelectionActive(selection, {
                    kind: 'item',
                    sectionId: section.id,
                    itemId: item.id,
                  })}
                  className="pl-8"
                >
                  <button
                    type="button"
                    onClick={() =>
                      select({
                        kind: 'item',
                        sectionId: section.id,
                        itemId: item.id,
                      })
                    }
                  >
                    <ListItem.Content>
                      <ListItem.Title className="font-normal text-sm">
                        {item.name}
                      </ListItem.Title>
                    </ListItem.Content>
                  </button>
                </ListItem>
              ))}
          </div>
        );
      })}
    </nav>
  );
};

export type { SectionTreeProps };
export { SectionTree };
'use client';

import {
  Colors,
  Grid01,
  Image01,
  LayersTwo01,
  Settings01,
  Tag01,
} from '@untitledui-pro/icons/solid';
import type { MenuEditorPanel } from '@/components/menu-builder/types';
import { ToolbarMenu } from '@/components/toolbar-menu';
import { cn } from '@/lib/utils/classnames';

const PANEL_INDEX: Record<MenuEditorPanel, number> = {
  brand: 0,
  typography: 1,
  layout: 2,
  background: 3,
  section: 4,
  item: 5,
};

const INDEX_TO_PANEL = Object.entries(PANEL_INDEX) as Array<
  [MenuEditorPanel, number]
>;

function panelToIndex(panel: MenuEditorPanel | null): number | null {
  if (panel === null) return null;
  return PANEL_INDEX[panel];
}

function indexToPanel(index: number | null): MenuEditorPanel | null {
  if (index === null) return null;
  const found = INDEX_TO_PANEL.find(([, i]) => i === index);
  return found?.[0] ?? null;
}

interface MenuToolbarTab {
  panel: MenuEditorPanel;
  label: string;
  icon: React.ReactNode;
}

const defaultTabs: MenuToolbarTab[] = [
  { panel: 'brand', label: 'Brand', icon: <Colors className="size-4" /> },
  {
    panel: 'typography',
    label: 'Type',
    icon: <Settings01 className="size-4" />,
  },
  { panel: 'layout', label: 'Layout', icon: <Grid01 className="size-4" /> },
  {
    panel: 'background',
    label: 'Background',
    icon: <Image01 className="size-4" />,
  },
  {
    panel: 'section',
    label: 'Section',
    icon: <LayersTwo01 className="size-4" />,
  },
  { panel: 'item', label: 'Item', icon: <Tag01 className="size-4" /> },
];

interface MenuToolbarProps extends React.ComponentPropsWithRef<'div'> {
  /** Which settings panel is open in the morphing toolbar. */
  activePanel: MenuEditorPanel | null;
  onActivePanelChange?: (panel: MenuEditorPanel | null) => void;
  /** Panel body keyed by slot — same content as MenuInspector. */
  panels: Partial<Record<MenuEditorPanel, React.ReactNode>>;
  tabs?: MenuToolbarTab[];
}

/**
 * Floating bottom toolbar for the menu editor. Wraps {@link ToolbarMenu} and maps
 * editor panel slots to morphing tabs. Panel content is container-agnostic.
 */
const MenuToolbar = ({
  activePanel,
  onActivePanelChange,
  panels,
  tabs = defaultTabs,
  className,
  ref,
  ...props
}: MenuToolbarProps) => {
  const selectedIndex = panelToIndex(activePanel);

  return (
    <ToolbarMenu
      ref={ref}
      className={cn(className)}
      selected={selectedIndex}
      onSelectedChange={(index) => onActivePanelChange?.(indexToPanel(index))}
      closeOnOutsideClick={false}
      {...props}
    >
      <ToolbarMenu.Container>
        <ToolbarMenu.Panels>
          {tabs.map((tab) => (
            <ToolbarMenu.Panel key={tab.panel} index={PANEL_INDEX[tab.panel]}>
              <div className="flex min-h-28 flex-col gap-3 p-3">
                {panels[tab.panel] ?? (
                  <p className="text-foreground/50 text-xs">
                    No panel content for {tab.label}.
                  </p>
                )}
              </div>
            </ToolbarMenu.Panel>
          ))}
        </ToolbarMenu.Panels>

        <ToolbarMenu.Bar>
          <ToolbarMenu.Tabs>
            {tabs.map((tab) => (
              <ToolbarMenu.Tab
                key={tab.panel}
                index={PANEL_INDEX[tab.panel]}
                icon={tab.icon}
                label={tab.label}
              />
            ))}
          </ToolbarMenu.Tabs>
        </ToolbarMenu.Bar>
      </ToolbarMenu.Container>
    </ToolbarMenu>
  );
};

export type { MenuToolbarProps, MenuToolbarTab };
export { MenuToolbar, PANEL_INDEX, panelToIndex };
'use client';

import { Inspector } from '@/components/inspector';
import type { MenuEditorPanel } from '@/components/menu-builder/types';
import { cn } from '@/lib/utils/classnames';

const PANEL_TITLES: Record<MenuEditorPanel, string> = {
  brand: 'Brand',
  typography: 'Typography',
  layout: 'Layout',
  background: 'Background',
  section: 'Section',
  item: 'Item',
};

interface MenuInspectorProps extends React.ComponentPropsWithRef<'section'> {
  activePanel: MenuEditorPanel | null;
  panels: Partial<Record<MenuEditorPanel, React.ReactNode>>;
  title?: string;
  icon?: React.ReactNode;
  defaultPinned?: boolean;
}

/**
 * Right-side inspector for the menu editor. Renders the same panel content as
 * {@link MenuToolbar} — never fork panel logic per container.
 */
const MenuInspector = ({
  activePanel,
  panels,
  title,
  icon,
  defaultPinned = true,
  className,
  ref,
  ...props
}: MenuInspectorProps) => {
  const resolvedTitle =
    title ?? (activePanel ? PANEL_TITLES[activePanel] : 'Inspector');

  return (
    <Inspector
      ref={ref}
      defaultPinned={defaultPinned}
      className={cn(className)}
      {...props}
    >
      <Inspector.Handle />
      <Inspector.Panel>
        <Inspector.Header title={resolvedTitle} icon={icon} showClose />
        <Inspector.Content>
          {activePanel && panels[activePanel] ? (
            panels[activePanel]
          ) : (
            <p className="text-foreground-secondary text-sm">
              Select brand, a section, or an item on the canvas to edit
              properties.
            </p>
          )}
        </Inspector.Content>
      </Inspector.Panel>
    </Inspector>
  );
};

export type { MenuInspectorProps };
export { MenuInspector };
import { cn } from '@/lib/utils/classnames';

interface PanelSectionProps extends React.ComponentPropsWithRef<'div'> {
  label?: string;
}

/** Labelled group for toolbar / inspector panel content. Parent owns outer gap. */
const PanelSection = ({
  label,
  className,
  children,
  ref,
  ...props
}: PanelSectionProps) => (
  <div ref={ref} className={cn('flex flex-col gap-2', className)} {...props}>
    {label ? (
      <p className="font-medium text-[10px] text-foreground/50 uppercase tracking-wider">
        {label}
      </p>
    ) : null}
    {children}
  </div>
);

interface PanelRowProps extends React.ComponentPropsWithRef<'div'> {
  label: string;
}

/** Compact settings row: label left, control right. */
const PanelRow = ({
  label,
  className,
  children,
  ref,
  ...props
}: PanelRowProps) => (
  <div
    ref={ref}
    className={cn('flex items-center justify-between gap-3 text-xs', className)}
    {...props}
  >
    <span className="text-foreground/60">{label}</span>
    <div className="flex min-w-0 shrink-0 items-center gap-1">{children}</div>
  </div>
);

export type { PanelRowProps, PanelSectionProps };
export { PanelRow, PanelSection };

Overview

These primitives implement the visual editor described in the Playstack QR menu product brief: preview-first layout (centered phone stage, right control column on desktop), layers in a drawer, and three editor modes (Presets · Design · Menu). Toolbar / inspector panels are a later pass.

Menu chrome (sidebar, header, publish) uses library tokens. The menu preview reads --menu-* variables from MenuThemeProvider.

Primitives

ExportRole
MenuThemeProviderMaps MenuTheme--menu-* CSS variables
MenuBuilderLayoutEditor shell — header, modes, stage, control column
MenuCanvasPhone-frame preview + selection (lives in Stage)
MenuSectionView / MenuItemViewPublished menu surface (list, cards, grid, editorial)
SectionTreeLeft outline synced to EditorSelection
MenuToolbarBottom morphing toolbar (wraps ToolbarMenu)
MenuInspectorRight panel (wraps Inspector)
PanelSection / PanelRowShared panel layout rhythm
buildMenuBuilderPanelsExample panel bodies for demos

Selection model


          type EditorSelection =
  | { kind: 'none' }
  | { kind: 'brand' }
  | { kind: 'section'; id: string }
  | { kind: 'item'; sectionId: string; itemId: string };
        

Use selectionToPanel() to open the matching toolbar / inspector slot.

Editor preview

Harbour Room
Draft

Starters

Mains

import { MenuBuilderEditor } from '@/components/demos/menu-builder-editor';

export const meta = {
  layout: 'fullscreen',
} as const;

export default function MenuBuilderEditorPreview() {
  return <MenuBuilderEditor />;
}

Open these first:

Both show the same editor — layers rail, phone canvas, morphing toolbar, and inspector with shared panel content.

Previous

Menu

Next

Modal