From 988d01ae5173156173bf1fc64c3d6e47daa5f5e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 07:56:17 +0000 Subject: [PATCH 1/2] 0.73.0 - Add BrowserFrame component for mock browser window screenshots Introduces a composable BrowserFrame component for rendering content inside a mock browser chrome (macOS / Windows / minimal variants). Useful for landing pages, product screenshots, and documentation. Includes: - BrowserFrame, BrowserFrameHeader, BrowserFrameControls, BrowserFrameNavButtons, BrowserFrameAddressBar, BrowserFrameTabs, BrowserFrameTab, BrowserFrameContent subcomponents - Theming via card/background/border/muted tokens from @schemavaults/theme - Variants (macos, windows, minimal) and sizes (sm, md, lg) - Storybook stories covering all variants, tabs, editable address bar, fixed aspect ratio, and insecure (http) URL leading icon --- package.json | 2 +- .../ui/browser-frame/BrowserFrame.stories.tsx | 253 ++++++++ .../ui/browser-frame/browser-frame.tsx | 559 ++++++++++++++++++ src/components/ui/browser-frame/index.ts | 2 + src/components/ui/index.ts | 3 + 5 files changed, 818 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/browser-frame/BrowserFrame.stories.tsx create mode 100644 src/components/ui/browser-frame/browser-frame.tsx create mode 100644 src/components/ui/browser-frame/index.ts diff --git a/package.json b/package.json index 7e0a35e..9d2e2ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/ui", - "version": "0.72.3", + "version": "0.73.0", "private": false, "license": "UNLICENSED", "description": "React.js UI components for SchemaVaults frontend applications", diff --git a/src/components/ui/browser-frame/BrowserFrame.stories.tsx b/src/components/ui/browser-frame/BrowserFrame.stories.tsx new file mode 100644 index 0000000..7596cab --- /dev/null +++ b/src/components/ui/browser-frame/BrowserFrame.stories.tsx @@ -0,0 +1,253 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import type { ReactElement } from "react"; + +import { + BrowserFrame, + BrowserFrameAddressBar, + BrowserFrameContent, + BrowserFrameControls, + BrowserFrameHeader, + BrowserFrameNavButtons, + BrowserFrameTab, + BrowserFrameTabs, + browserFrameSizeIds, + browserFrameVariantIds, +} from "./browser-frame"; + +const meta = { + title: "Components/BrowserFrame", + component: BrowserFrame, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], + argTypes: { + variant: { + options: browserFrameVariantIds, + control: { type: "radio" }, + }, + size: { + options: browserFrameSizeIds, + control: { type: "radio" }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function PlaceholderContent(): ReactElement { + return ( +
+
+ +
+ ); +} + +export const Default: Story = { + args: { + variant: "macos", + size: "md", + }, + render: (args): ReactElement => { + const variant = args.variant ?? "macos"; + const size = args.size ?? "md"; + return ( + + + + + + + + + + ); + }, +}; + +export const MacOS: Story = { + render: (): ReactElement => ( + + + + + + + + + + + ), +}; + +export const Windows: Story = { + render: (): ReactElement => ( + + + + + + + + + + + ), +}; + +export const Minimal: Story = { + render: (): ReactElement => ( + + + + + + + + + ), +}; + +export const WithTabs: Story = { + render: (): ReactElement => ( + + + + + + + + + + + + + + + ), +}; + +export const InsecureUrl: Story = { + render: (): ReactElement => ( + + + + + + + + + + ), +}; + +export const EditableAddress: Story = { + render: (): ReactElement => ( + + + + + + + + + + ), +}; + +export const FixedAspectRatio: Story = { + render: (): ReactElement => ( + + + + + + + + + + ), +}; + +function AllVariantsExample(): ReactElement { + return ( +
+ {browserFrameVariantIds.map((variant) => ( +
+ + {variant} + + + + {variant !== "minimal" ? ( + + ) : null} + + {variant === "windows" ? ( + + ) : null} + + +
+ {variant} variant +
+
+
+
+ ))} +
+ ); +} + +export const AllVariants: Story = { + render: (): ReactElement => , +}; + +function AllSizesExample(): ReactElement { + return ( +
+ {browserFrameSizeIds.map((size) => ( +
+ + {size} + + + + + + + +
+ {size} +
+
+
+
+ ))} +
+ ); +} + +export const AllSizes: Story = { + render: (): ReactElement => , +}; diff --git a/src/components/ui/browser-frame/browser-frame.tsx b/src/components/ui/browser-frame/browser-frame.tsx new file mode 100644 index 0000000..36b423e --- /dev/null +++ b/src/components/ui/browser-frame/browser-frame.tsx @@ -0,0 +1,559 @@ +"use client"; + +import { cva, type VariantProps } from "class-variance-authority"; +import type { + HTMLAttributes, + InputHTMLAttributes, + ReactElement, + ReactNode, + Ref, +} from "react"; + +import { cn } from "@/lib/utils"; + +export const browserFrameVariantIds = [ + "macos", + "windows", + "minimal", +] as const satisfies string[]; + +export type BrowserFrameVariantId = (typeof browserFrameVariantIds)[number]; + +export const browserFrameSizeIds = [ + "sm", + "md", + "lg", +] as const satisfies string[]; + +export type BrowserFrameSizeId = (typeof browserFrameSizeIds)[number]; + +const browserFrameVariants = cva( + "flex w-full flex-col overflow-hidden border bg-card text-card-foreground shadow-lg", + { + variants: { + variant: { + macos: "rounded-xl border-border", + windows: "rounded-md border-border", + minimal: "rounded-lg border-border", + } satisfies Record, + size: { + sm: "text-xs", + md: "text-sm", + lg: "text-base", + } satisfies Record, + }, + defaultVariants: { + variant: "macos", + size: "md", + }, + }, +); + +export interface BrowserFrameProps + extends HTMLAttributes, + VariantProps { + ref?: Ref; +} + +function BrowserFrame({ + className, + variant, + size, + ref, + ...props +}: BrowserFrameProps): ReactElement { + return ( +
+ ); +} +BrowserFrame.displayName = "BrowserFrame"; + +const browserFrameHeaderVariants = cva( + "flex items-center gap-3 border-b bg-muted/60 backdrop-blur-sm", + { + variants: { + variant: { + macos: "border-border px-3 py-2.5", + windows: "border-border px-2 py-1.5", + minimal: "border-border px-3 py-2", + } satisfies Record, + size: { + sm: "min-h-8", + md: "min-h-10", + lg: "min-h-12", + } satisfies Record, + }, + defaultVariants: { + variant: "macos", + size: "md", + }, + }, +); + +export interface BrowserFrameHeaderProps + extends HTMLAttributes, + VariantProps { + ref?: Ref; +} + +function BrowserFrameHeader({ + className, + variant, + size, + ref, + ...props +}: BrowserFrameHeaderProps): ReactElement { + return ( +
+ ); +} +BrowserFrameHeader.displayName = "BrowserFrameHeader"; + +export interface BrowserFrameControlsProps extends HTMLAttributes { + variant?: BrowserFrameVariantId; + /** + * Disable the visual difference between hover/idle dots. When true, + * the controls render as non-interactive decorations only. + */ + decorative?: boolean; + ref?: Ref; +} + +const macosControlColors = [ + { + label: "Close", + className: "bg-[#ff5f57] border-[#e0443e]", + }, + { + label: "Minimize", + className: "bg-[#febc2e] border-[#dea123]", + }, + { + label: "Maximize", + className: "bg-[#28c840] border-[#1aab29]", + }, +] as const; + +function BrowserFrameControls({ + className, + variant = "macos", + decorative = true, + ref, + ...props +}: BrowserFrameControlsProps): ReactElement { + if (variant === "minimal") { + return ( +
+ ); + } + + if (variant === "windows") { + return ( +
+ + + + + + + + + +
+ ); + } + + return ( +
+ {macosControlColors.map((control) => ( + + ))} +
+ ); +} +BrowserFrameControls.displayName = "BrowserFrameControls"; + +export interface BrowserFrameNavButtonsProps + extends HTMLAttributes { + ref?: Ref; +} + +function BrowserFrameNavButtons({ + className, + ref, + ...props +}: BrowserFrameNavButtonsProps): ReactElement { + return ( + + ); +} +BrowserFrameNavButtons.displayName = "BrowserFrameNavButtons"; + +export interface BrowserFrameAddressBarProps + extends Omit, "size"> { + /** + * Optional icon (e.g. a lock/globe) rendered at the start of the bar. + * Defaults to a lock icon for HTTPS-looking URLs, globe otherwise. + */ + leadingIcon?: ReactNode; + /** + * Optional element rendered at the end (e.g. star, share). + */ + trailingIcon?: ReactNode; + /** + * When true, the bar is rendered as a static label rather than an input. + * This is the default — most usages are decorative. + */ + readOnly?: boolean; + ref?: Ref; +} + +function defaultLeadingIcon(value: string | number | readonly string[] | undefined): ReactElement { + const url = typeof value === "string" ? value : ""; + const isSecure = url.startsWith("https://") || url === ""; + if (isSecure) { + return ( + + ); + } + return ( + + ); +} + +function BrowserFrameAddressBar({ + className, + value, + defaultValue, + leadingIcon, + trailingIcon, + readOnly = true, + ref, + ...props +}: BrowserFrameAddressBarProps): ReactElement { + const resolvedLeadingIcon = + leadingIcon ?? defaultLeadingIcon(value ?? defaultValue); + return ( +
+ {resolvedLeadingIcon ? ( + {resolvedLeadingIcon} + ) : null} + + {trailingIcon ? ( + {trailingIcon} + ) : null} +
+ ); +} +BrowserFrameAddressBar.displayName = "BrowserFrameAddressBar"; + +export interface BrowserFrameTabsProps extends HTMLAttributes { + ref?: Ref; +} + +function BrowserFrameTabs({ + className, + ref, + ...props +}: BrowserFrameTabsProps): ReactElement { + return ( +
+ ); +} +BrowserFrameTabs.displayName = "BrowserFrameTabs"; + +export interface BrowserFrameTabProps + extends HTMLAttributes { + active?: boolean; + /** Optional favicon node (image, emoji, or svg). */ + favicon?: ReactNode; + /** When true, shows a close affordance on hover. */ + closable?: boolean; + ref?: Ref; +} + +function BrowserFrameTab({ + className, + active = false, + favicon, + closable = false, + children, + ref, + ...props +}: BrowserFrameTabProps): ReactElement { + return ( +
+ {favicon ? ( + + {favicon} + + ) : null} + {children} + {closable ? ( + + ) : null} +
+ ); +} +BrowserFrameTab.displayName = "BrowserFrameTab"; + +export interface BrowserFrameContentProps + extends HTMLAttributes { + /** + * When provided, the content area is rendered with a fixed aspect ratio, + * useful for screenshot embeds. Expressed as `width / height`. + */ + aspectRatio?: string; + ref?: Ref; +} + +function BrowserFrameContent({ + className, + style, + aspectRatio, + ref, + ...props +}: BrowserFrameContentProps): ReactElement { + return ( +
+ ); +} +BrowserFrameContent.displayName = "BrowserFrameContent"; + +export { + BrowserFrame, + BrowserFrameHeader, + BrowserFrameControls, + BrowserFrameNavButtons, + BrowserFrameAddressBar, + BrowserFrameTabs, + BrowserFrameTab, + BrowserFrameContent, + browserFrameVariants, + browserFrameHeaderVariants, +}; diff --git a/src/components/ui/browser-frame/index.ts b/src/components/ui/browser-frame/index.ts new file mode 100644 index 0000000..fbdbfad --- /dev/null +++ b/src/components/ui/browser-frame/index.ts @@ -0,0 +1,2 @@ +export * from "./browser-frame"; +export type * from "./browser-frame"; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 4a4627c..df4b4a7 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -293,3 +293,6 @@ export type * from "./time-picker"; export * from "./mark"; export type * from "./mark"; + +export * from "./browser-frame"; +export type * from "./browser-frame"; From f08b97ffedc0ab669e661df81dedc0b101b8104c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 12:39:58 +0000 Subject: [PATCH 2/2] 0.73.0 - Fix AllVariants story rendering Windows controls on both sides of the BrowserFrame --- src/components/ui/browser-frame/BrowserFrame.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/browser-frame/BrowserFrame.stories.tsx b/src/components/ui/browser-frame/BrowserFrame.stories.tsx index 7596cab..f0057d8 100644 --- a/src/components/ui/browser-frame/BrowserFrame.stories.tsx +++ b/src/components/ui/browser-frame/BrowserFrame.stories.tsx @@ -199,7 +199,7 @@ function AllVariantsExample(): ReactElement { - {variant !== "minimal" ? ( + {variant === "macos" ? ( ) : null}