Compare commits

...

9 Commits

Author SHA1 Message Date
Raunak Bhagat
c20e5205ca feat(opal): Add AuxiliaryTag component and resync colors.css with Figma
Add AuxiliaryTag component (green/blue/purple/amber/gray) with icon support.

Resync all color tokens in colors.css against Figma source of truth (20 fixes):
- Fix neon alpha variant naming: -60/-30 → -a60/-a30 to disambiguate from
  Figma scale levels (e.g. --neon-amber-60 is now scale Neon/Amber/60,
  --neon-amber-a60 is the 40-at-60%-opacity highlight variant)
- Add neon scale primitives for yellow, lime, cyan, sky, magenta (50, 20, 05
  for light mode; 80, 90 for dark mode)
- Fix all neon-based theme tokens (yellow, lime, cyan, sky, magenta) from
  alpha variants to correct Figma solid swatches
- Fix background-neutral-inverted-04 (grey-75→grey-60) and -03 (grey-80→grey-75)
- Fix background-tint-inverted-04 (tint-80→tint-60)
- Fix theme-gradient-00 (grey-00→grey-100)
- Fix mask-01 (alpha-grey-100→alpha-grey-00)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 00:11:54 -08:00
Raunak Bhagat
19151c2c44 fix(opal): Auto-grow LabelLayout edit input to match content width
- Replace flex-1 input with inline-grid sizer pattern: a hidden mirror
  span and the input share the same grid cell, so the input grows
  horizontally as content is typed
- Set input size=1 to eliminate browser default intrinsic width
- Accessories (Optional, auxIcon) stay beside the input during editing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 23:37:27 -08:00
Raunak Bhagat
72b1771bc2 feat(opal): Add auxIcon accessory to LabelLayout
- Add `auxIcon?: "info-gray" | "info-blue" | "warning" | "error"` prop
- Renders a status icon beside the title with p-0.5 padding
  (icon size = lineHeight - 4px, auto-scales per preset)
- Icon/color mapping: info-gray (AlertCircle/text-02),
  info-blue (AlertCircle/status-info-05), warning (AlertTriangle/status-warning-05),
  error (XOctagon/status-error-05)
- Title row order: [title, (Optional), aux-icon, edit-button]
- Update storybook with auxIcon examples and combined accessories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 23:29:42 -08:00
Raunak Bhagat
8778266521 feat(opal): Add optional indicator accessory to LabelLayout
- Add `optional?: boolean` prop to LabelContentProps (LabelLayout only)
- Renders "(Optional)" beside the title in the muted font variant with text-03
- Muted font mapping: main-content → font-main-content-muted,
  main-ui → font-main-ui-muted, secondary → font-secondary-action (no muted variant)
- Update storybook with optional indicator examples for all LabelLayout presets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 23:13:41 -08:00
Raunak Bhagat
a8cbba86b4 feat(opal): Add BodyLayout component with orientation and prominence
- New BodyLayout for main-content/main-ui/secondary presets with body variant
- Three orientations: inline (icon left), vertical (icon top), reverse (title left)
- Two prominences: default (text-04) and muted (text-03)
- Read-only layout — no editing or descriptions supported
- Wire up Content router to dispatch variant="body" to BodyLayout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:54:26 -08:00
Raunak Bhagat
fb21c6cae5 refactor(opal): Inline presets, optional props, and edit UX improvements
- Delete presets.ts; inline HeadingLayout config into HeadingLayout.tsx
  and shared types into components.tsx (eliminates confusing unused entries)
- Make LabelLayout description optional (was required)
- Auto-select text when entering edit mode in both HeadingLayout and LabelLayout
- Update README with separate HeadingLayout/LabelLayout preset tables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:25:23 -08:00
Raunak Bhagat
922154f3d6 feat(opal): Add LabelLayout component and 2xs button size
Add LabelLayout for main-content/main-ui/secondary presets with
mandatory description, per-preset icon color, and editable support.
Add 2xs interactive container size (1rem/16px) with mini rounding
variant to support secondary-scale edit buttons without layout flash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:03:03 -08:00
Raunak Bhagat
5e9ea9edef refactor(opal): Rename LineItemLayout to Content with two-axis architecture
Restructure the component into a Content router that dispatches to
internal layout components based on sizePreset and variant axes.
Implement HeadingLayout with parameterized sizing from presets config,
scaled edit button sizes to prevent layout flash on edit toggle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:08:47 -08:00
Raunak Bhagat
dfc31e5d37 feat(opal): Add LineItemLayout component with editable headline variant
Introduces the LineItemLayout component in the opal design system library,
matching the Figma "Content Container" spec. Supports headline variant with
icon placement (left/top) and inline title editing with hover-revealed edit button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:33:05 -08:00
13 changed files with 1832 additions and 59 deletions

View File

@@ -15,6 +15,7 @@ const iconPaddingInRemVariants = {
md: "p-0.5",
sm: "p-0",
xs: "p-0.5",
"2xs": "p-0",
fit: "p-0.5",
} as const;
const iconSizeInRemVariants = {
@@ -22,6 +23,7 @@ const iconSizeInRemVariants = {
md: 1,
sm: 1,
xs: 0.75,
"2xs": 0.75,
fit: 1,
} as const;
@@ -128,7 +130,9 @@ function Button({
type={type}
border={interactiveBaseProps.prominence === "secondary"}
heightVariant={size}
roundingVariant={isLarge ? "default" : "compact"}
roundingVariant={
isLarge ? "default" : size === "2xs" ? "mini" : "compact"
}
>
<div
className={cn(

View File

@@ -12,3 +12,18 @@ export {
OpenButton,
type OpenButtonProps,
} from "@opal/components/buttons/OpenButton/components";
/* Content */
export {
Content,
type ContentProps,
type SizePreset,
type ContentVariant,
} from "@opal/components/layouts/Content/components";
/* AuxiliaryTag */
export {
AuxiliaryTag,
type AuxiliaryTagProps,
type AuxiliaryTagColor,
} from "@opal/components/tags/AuxiliaryTag/components";

View File

@@ -0,0 +1,121 @@
"use client";
import type { IconFunctionComponent } from "@opal/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type BodySizePreset = "main-content" | "main-ui" | "secondary";
type BodyOrientation = "vertical" | "inline" | "reverse";
type BodyProminence = "default" | "muted";
interface BodyPresetConfig {
/** Icon width/height (CSS value). */
iconSize: string;
/** Tailwind padding class for the icon container. */
iconContainerPadding: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Gap between icon container and title (CSS value). */
gap: string;
}
/** Props for {@link BodyLayout}. Does not support editing or descriptions. */
interface BodyLayoutProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
/** Main title text (read-only — editing is not supported). */
title: string;
/** Size preset. Default: `"main-ui"`. */
sizePreset?: BodySizePreset;
/** Layout orientation. Default: `"inline"`. */
orientation?: BodyOrientation;
/** Title prominence. Default: `"default"`. */
prominence?: BodyProminence;
}
// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------
const BODY_PRESETS: Record<BodySizePreset, BodyPresetConfig> = {
"main-content": {
iconSize: "1rem",
iconContainerPadding: "p-1",
titleFont: "font-main-content-body",
lineHeight: "1.5rem",
gap: "0.125rem",
},
"main-ui": {
iconSize: "1rem",
iconContainerPadding: "p-0.5",
titleFont: "font-main-ui-action",
lineHeight: "1.25rem",
gap: "0.25rem",
},
secondary: {
iconSize: "0.75rem",
iconContainerPadding: "p-0.5",
titleFont: "font-secondary-action",
lineHeight: "1rem",
gap: "0.125rem",
},
};
// ---------------------------------------------------------------------------
// BodyLayout
// ---------------------------------------------------------------------------
function BodyLayout({
icon: Icon,
title,
sizePreset = "main-ui",
orientation = "inline",
prominence = "default",
}: BodyLayoutProps) {
const config = BODY_PRESETS[sizePreset];
const titleColorClass =
prominence === "muted" ? "text-text-03" : "text-text-04";
return (
<div
className="opal-content-body"
data-orientation={orientation}
style={{ gap: config.gap }}
>
{Icon && (
<div
className={`opal-content-body-icon-container shrink-0 ${config.iconContainerPadding}`}
style={{ minHeight: config.lineHeight }}
>
<Icon
className="opal-content-body-icon text-text-03"
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
)}
<span
className={`opal-content-body-title ${config.titleFont} ${titleColorClass}`}
style={{ height: config.lineHeight }}
>
{title}
</span>
</div>
);
}
export {
BodyLayout,
type BodyLayoutProps,
type BodySizePreset,
type BodyOrientation,
type BodyProminence,
};

View File

@@ -0,0 +1,179 @@
"use client";
import { Button } from "@opal/components/buttons/Button/components";
import type { InteractiveContainerHeightVariant } from "@opal/core";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent } from "@opal/types";
import { useRef, useState } from "react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type HeadingSizePreset = "headline" | "section";
type HeadingVariant = "heading" | "section";
interface HeadingPresetConfig {
/** Icon width/height (CSS value). */
iconSize: string;
/** Tailwind padding class for the icon container. */
iconContainerPadding: string;
/** Gap between icon container and content (CSS value). */
gap: string;
/** Tailwind font class for the title. */
titleFont: string;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Button `size` prop for the edit button. */
editButtonSize: InteractiveContainerHeightVariant;
/** Tailwind padding class for the edit button container. */
editButtonPadding: string;
}
interface HeadingLayoutProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
/** Main title text. */
title: string;
/** Optional description below the title. */
description?: string;
/** Enable inline editing of the title. */
editable?: boolean;
/** Called when the user commits an edit. */
onTitleChange?: (newTitle: string) => void;
/** Size preset. Default: `"headline"`. */
sizePreset?: HeadingSizePreset;
/** Variant controls icon placement. `"heading"` = top, `"section"` = inline. Default: `"heading"`. */
variant?: HeadingVariant;
}
// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------
const HEADING_PRESETS: Record<HeadingSizePreset, HeadingPresetConfig> = {
headline: {
iconSize: "2rem",
iconContainerPadding: "p-0.5",
gap: "0.25rem",
titleFont: "font-heading-h2",
lineHeight: "2.25rem",
editButtonSize: "md",
editButtonPadding: "p-1",
},
section: {
iconSize: "1.25rem",
iconContainerPadding: "p-1",
gap: "0rem",
titleFont: "font-heading-h3",
lineHeight: "1.75rem",
editButtonSize: "sm",
editButtonPadding: "p-0.5",
},
};
// ---------------------------------------------------------------------------
// HeadingLayout
// ---------------------------------------------------------------------------
function HeadingLayout({
sizePreset = "headline",
variant = "heading",
icon: Icon,
title,
description,
editable,
onTitleChange,
}: HeadingLayoutProps) {
const [editing, setEditing] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const config = HEADING_PRESETS[sizePreset];
const iconPlacement = variant === "heading" ? "top" : "left";
function commit() {
if (!inputRef.current) return;
const value = inputRef.current.value.trim();
if (value && value !== title) onTitleChange?.(value);
setEditing(false);
}
return (
<div
className="opal-content-heading"
data-icon-placement={iconPlacement}
style={{ gap: iconPlacement === "left" ? config.gap : undefined }}
>
{Icon && (
<div
className={`opal-content-heading-icon-container shrink-0 ${config.iconContainerPadding}`}
style={{ minHeight: config.lineHeight }}
>
<Icon
className="opal-content-heading-icon"
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
)}
<div className="opal-content-heading-body">
<div className="opal-content-heading-title-row">
{editing ? (
<input
ref={inputRef}
className={`opal-content-heading-input ${config.titleFont} text-text-04`}
defaultValue={title}
autoFocus
onFocus={(e) => e.currentTarget.select()}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
style={{ height: config.lineHeight }}
/>
) : (
<span
className={`opal-content-heading-title ${
config.titleFont
} text-text-04${editable ? " cursor-pointer" : ""}`}
onClick={editable ? () => setEditing(true) : undefined}
style={{ height: config.lineHeight }}
>
{title}
</span>
)}
{editable && !editing && (
<div
className={`opal-content-heading-edit-button ${config.editButtonPadding}`}
>
<Button
icon={SvgEdit}
prominence="internal"
size={config.editButtonSize}
tooltip="Edit"
tooltipSide="right"
onClick={() => setEditing(true)}
/>
</div>
)}
</div>
{description && (
<div className="opal-content-heading-description font-secondary-body text-text-03">
{description}
</div>
)}
</div>
</div>
);
}
export { HeadingLayout, type HeadingLayoutProps, type HeadingSizePreset };

View File

@@ -0,0 +1,254 @@
"use client";
import { Button } from "@opal/components/buttons/Button/components";
import type { InteractiveContainerHeightVariant } from "@opal/core";
import SvgAlertCircle from "@opal/icons/alert-circle";
import SvgAlertTriangle from "@opal/icons/alert-triangle";
import SvgEdit from "@opal/icons/edit";
import SvgXOctagon from "@opal/icons/x-octagon";
import type { IconFunctionComponent } from "@opal/types";
import { useRef, useState } from "react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type LabelSizePreset = "main-content" | "main-ui" | "secondary";
type LabelAuxIcon = "info-gray" | "info-blue" | "warning" | "error";
interface LabelPresetConfig {
iconSize: string;
iconContainerPadding: string;
iconColorClass: string;
titleFont: string;
lineHeight: string;
gap: string;
editButtonSize: InteractiveContainerHeightVariant;
editButtonPadding: string;
optionalFont: string;
/** Aux icon size = lineHeight 2 × p-0.5. */
auxIconSize: string;
}
interface LabelLayoutProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
/** Main title text. */
title: string;
/** Optional description text below the title. */
description?: string;
/** Enable inline editing of the title. */
editable?: boolean;
/** Called when the user commits an edit. */
onTitleChange?: (newTitle: string) => void;
/** When `true`, renders "(Optional)" beside the title. */
optional?: boolean;
/** Auxiliary status icon rendered beside the title. */
auxIcon?: LabelAuxIcon;
/** Size preset. Default: `"main-ui"`. */
sizePreset?: LabelSizePreset;
}
// ---------------------------------------------------------------------------
// Presets
// ---------------------------------------------------------------------------
const LABEL_PRESETS: Record<LabelSizePreset, LabelPresetConfig> = {
"main-content": {
iconSize: "1rem",
iconContainerPadding: "p-1",
iconColorClass: "text-text-04",
titleFont: "font-main-content-emphasis",
lineHeight: "1.5rem",
gap: "0.125rem",
editButtonSize: "sm",
editButtonPadding: "p-0",
optionalFont: "font-main-content-muted",
auxIconSize: "1.25rem",
},
"main-ui": {
iconSize: "1rem",
iconContainerPadding: "p-0.5",
iconColorClass: "text-text-03",
titleFont: "font-main-ui-action",
lineHeight: "1.25rem",
gap: "0.25rem",
editButtonSize: "xs",
editButtonPadding: "p-0",
optionalFont: "font-main-ui-muted",
auxIconSize: "1rem",
},
secondary: {
iconSize: "0.75rem",
iconContainerPadding: "p-0.5",
iconColorClass: "text-text-04",
titleFont: "font-secondary-action",
lineHeight: "1rem",
gap: "0.125rem",
editButtonSize: "2xs",
editButtonPadding: "p-0",
optionalFont: "font-secondary-action",
auxIconSize: "0.75rem",
},
};
// ---------------------------------------------------------------------------
// LabelLayout
// ---------------------------------------------------------------------------
const AUX_ICON_CONFIG: Record<
LabelAuxIcon,
{ icon: IconFunctionComponent; colorClass: string }
> = {
"info-gray": { icon: SvgAlertCircle, colorClass: "text-text-02" },
"info-blue": { icon: SvgAlertCircle, colorClass: "text-status-info-05" },
warning: { icon: SvgAlertTriangle, colorClass: "text-status-warning-05" },
error: { icon: SvgXOctagon, colorClass: "text-status-error-05" },
};
function LabelLayout({
icon: Icon,
title,
description,
editable,
onTitleChange,
optional,
auxIcon,
sizePreset = "main-ui",
}: LabelLayoutProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
const config = LABEL_PRESETS[sizePreset];
function startEditing() {
setEditValue(title);
setEditing(true);
}
function commit() {
const value = editValue.trim();
if (value && value !== title) onTitleChange?.(value);
setEditing(false);
}
return (
<div className="opal-content-label" style={{ gap: config.gap }}>
{Icon && (
<div
className={`opal-content-label-icon-container shrink-0 ${config.iconContainerPadding}`}
style={{ minHeight: config.lineHeight }}
>
<Icon
className={`opal-content-label-icon ${config.iconColorClass}`}
style={{ width: config.iconSize, height: config.iconSize }}
/>
</div>
)}
<div className="opal-content-label-body">
<div className="opal-content-label-title-row">
{editing ? (
<div className="opal-content-label-input-sizer">
<span
className={`opal-content-label-input-mirror ${config.titleFont}`}
>
{editValue || "\u00A0"}
</span>
<input
ref={inputRef}
className={`opal-content-label-input ${config.titleFont} text-text-04`}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
size={1}
autoFocus
onFocus={(e) => e.currentTarget.select()}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
style={{ height: config.lineHeight }}
/>
</div>
) : (
<span
className={`opal-content-label-title ${
config.titleFont
} text-text-04${editable ? " cursor-pointer" : ""}`}
onClick={editable ? startEditing : undefined}
style={{ height: config.lineHeight }}
>
{title}
</span>
)}
{optional && (
<span
className={`${config.optionalFont} text-text-03 shrink-0`}
style={{ height: config.lineHeight }}
>
(Optional)
</span>
)}
{auxIcon &&
(() => {
const { icon: AuxIcon, colorClass } = AUX_ICON_CONFIG[auxIcon];
return (
<div
className="opal-content-label-aux-icon shrink-0 p-0.5"
style={{ height: config.lineHeight }}
>
<AuxIcon
className={colorClass}
style={{
width: config.auxIconSize,
height: config.auxIconSize,
}}
/>
</div>
);
})()}
{editable && !editing && (
<div
className={`opal-content-label-edit-button ${config.editButtonPadding}`}
>
<Button
icon={SvgEdit}
prominence="internal"
size={config.editButtonSize}
tooltip="Edit"
tooltipSide="right"
onClick={startEditing}
/>
</div>
)}
</div>
{description && (
<div className="opal-content-label-description font-secondary-body text-text-03">
{description}
</div>
)}
</div>
</div>
);
}
export {
LabelLayout,
type LabelLayoutProps,
type LabelSizePreset,
type LabelAuxIcon,
};

View File

@@ -0,0 +1,116 @@
# Content
**Import:** `import { Content, type ContentProps } from "@opal/components";`
A two-axis layout component for displaying icon + title + description rows. Routes to an internal layout based on the `sizePreset` and `variant` combination.
## Two-Axis Architecture
### `sizePreset` — controls sizing (icon, padding, gap, font)
#### HeadingLayout presets
| Preset | Icon | Icon padding | Gap | Title font | Line-height |
|---|---|---|---|---|---|
| `headline` | 2rem (32px) | `p-0.5` (2px) | 0.25rem (4px) | `font-heading-h2` | 2.25rem (36px) |
| `section` | 1.25rem (20px) | `p-1` (4px) | 0rem | `font-heading-h3` | 1.75rem (28px) |
#### LabelLayout presets
| Preset | Icon | Icon padding | Icon color | Gap | Title font | Line-height |
|---|---|---|---|---|---|---|
| `main-content` | 1rem (16px) | `p-1` (4px) | `text-04` | 0.125rem (2px) | `font-main-content-emphasis` | 1.5rem (24px) |
| `main-ui` | 1rem (16px) | `p-0.5` (2px) | `text-03` | 0.25rem (4px) | `font-main-ui-action` | 1.25rem (20px) |
| `secondary` | 0.75rem (12px) | `p-0.5` (2px) | `text-04` | 0.125rem (2px) | `font-secondary-action` | 1rem (16px) |
> Icon container height (icon + 2 x padding) always equals the title line-height.
### `variant` — controls structure / layout
| variant | Description |
|---|---|
| `heading` | Icon on **top** (flex-col) — HeadingLayout |
| `section` | Icon **inline** (flex-row) — HeadingLayout or LabelLayout |
| `body` | Body text layout — BodyLayout (future) |
### Valid Combinations -> Internal Routing
| sizePreset | variant | Routes to |
|---|---|---|
| `headline` / `section` | `heading` | **HeadingLayout** (icon on top) |
| `headline` / `section` | `section` | **HeadingLayout** (icon inline) |
| `main-content` / `main-ui` / `secondary` | `section` | **LabelLayout** |
| `main-content` / `main-ui` / `secondary` | `body` | BodyLayout (future) |
Invalid combinations (e.g. `sizePreset="headline" + variant="body"`) are excluded at the type level.
## Props
| Prop | Type | Default | Description |
|---|---|---|---|
| `sizePreset` | `SizePreset` | `"headline"` | Size preset (see tables above) |
| `variant` | `ContentVariant` | `"heading"` | Layout variant (see table above) |
| `icon` | `IconFunctionComponent` | — | Optional icon component |
| `title` | `string` | **(required)** | Main title text |
| `description` | `string` | — | Optional description below the title |
| `editable` | `boolean` | `false` | Enable inline editing of the title |
| `onTitleChange` | `(newTitle: string) => void` | — | Called when user commits an edit |
## Internal Layouts
### HeadingLayout
For `headline` / `section` presets. Supports `variant="heading"` (icon on top) and `variant="section"` (icon inline). Description is always `font-secondary-body text-text-03`.
### LabelLayout
For `main-content` / `main-ui` / `secondary` presets. Always inline. Both `icon` and `description` are optional. Description is always `font-secondary-body text-text-03`.
## Usage Examples
```tsx
import { Content } from "@opal/components";
import SvgSearch from "@opal/icons/search";
// HeadingLayout — headline, icon on top
<Content
icon={SvgSearch}
sizePreset="headline"
variant="heading"
title="Agent Settings"
description="Configure your agent's behavior"
/>
// HeadingLayout — section, icon inline
<Content
icon={SvgSearch}
sizePreset="section"
variant="section"
title="Data Sources"
description="Connected integrations"
/>
// LabelLayout — with icon and description
<Content
icon={SvgSearch}
sizePreset="main-ui"
title="Instructions"
description="Agent system prompt"
/>
// LabelLayout — title only (no icon, no description)
<Content
sizePreset="main-content"
title="Featured Agent"
/>
// Editable title
<Content
icon={SvgSearch}
sizePreset="headline"
variant="heading"
title="My Agent"
editable
onTitleChange={(newTitle) => save(newTitle)}
/>
```

View File

@@ -0,0 +1,139 @@
import "@opal/components/layouts/Content/styles.css";
import {
BodyLayout,
type BodyOrientation,
type BodyProminence,
} from "@opal/components/layouts/Content/BodyLayout";
import {
HeadingLayout,
type HeadingLayoutProps,
} from "@opal/components/layouts/Content/HeadingLayout";
import {
LabelLayout,
type LabelLayoutProps,
} from "@opal/components/layouts/Content/LabelLayout";
import type { IconFunctionComponent } from "@opal/types";
// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------
type SizePreset =
| "headline"
| "section"
| "main-content"
| "main-ui"
| "secondary";
type ContentVariant = "heading" | "section" | "body";
interface ContentBaseProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
/** Main title text. */
title: string;
/** Optional description below the title. */
description?: string;
/** Enable inline editing of the title. */
editable?: boolean;
/** Called when the user commits an edit. */
onTitleChange?: (newTitle: string) => void;
}
// ---------------------------------------------------------------------------
// Discriminated union: valid sizePreset × variant combinations
// ---------------------------------------------------------------------------
type HeadingContentProps = ContentBaseProps & {
/** Size preset. Default: `"headline"`. */
sizePreset?: "headline" | "section";
/** Variant. Default: `"heading"` for heading-eligible presets. */
variant?: "heading" | "section";
};
type LabelContentProps = ContentBaseProps & {
sizePreset: "main-content" | "main-ui" | "secondary";
variant?: "section";
/** When `true`, renders "(Optional)" beside the title in the muted font variant. */
optional?: boolean;
/** Auxiliary status icon rendered beside the title. */
auxIcon?: "info-gray" | "info-blue" | "warning" | "error";
};
/** BodyLayout does not support descriptions or inline editing. */
type BodyContentProps = Omit<
ContentBaseProps,
"description" | "editable" | "onTitleChange"
> & {
sizePreset: "main-content" | "main-ui" | "secondary";
variant: "body";
/** Layout orientation. Default: `"inline"`. */
orientation?: BodyOrientation;
/** Title prominence. Default: `"default"`. */
prominence?: BodyProminence;
};
type ContentProps = HeadingContentProps | LabelContentProps | BodyContentProps;
// ---------------------------------------------------------------------------
// Content — routes to the appropriate internal layout
// ---------------------------------------------------------------------------
function Content(props: ContentProps) {
const { sizePreset = "headline", variant = "heading", ...rest } = props;
// Heading layout: headline/section presets with heading/section variant
if (sizePreset === "headline" || sizePreset === "section") {
return (
<HeadingLayout
sizePreset={sizePreset}
variant={variant as HeadingLayoutProps["variant"]}
{...rest}
/>
);
}
// Label layout: main-content/main-ui/secondary with section variant
if (variant === "section" || variant === "heading") {
return (
<LabelLayout
sizePreset={sizePreset}
{...(rest as Omit<LabelLayoutProps, "sizePreset">)}
/>
);
}
// Body layout: main-content/main-ui/secondary with body variant
if (variant === "body") {
return (
<BodyLayout
sizePreset={sizePreset}
{...(rest as Omit<
React.ComponentProps<typeof BodyLayout>,
"sizePreset"
>)}
/>
);
}
return null;
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export {
Content,
type ContentProps,
type SizePreset,
type ContentVariant,
type HeadingContentProps,
type LabelContentProps,
type BodyContentProps,
};

View File

@@ -0,0 +1,252 @@
/* ---------------------------------------------------------------------------
Content — HeadingLayout
Two icon placement modes (driven by variant):
left (variant="section") : flex-row — icon beside content
top (variant="heading") : flex-col — icon above content
Sizing (icon size, gap, padding, font, line-height) is driven by the
sizePreset prop via inline styles + Tailwind classes in the component.
--------------------------------------------------------------------------- */
/* ---------------------------------------------------------------------------
Layout — icon placement
--------------------------------------------------------------------------- */
.opal-content-heading {
@apply flex items-start;
}
.opal-content-heading[data-icon-placement="left"] {
@apply flex-row;
}
.opal-content-heading[data-icon-placement="top"] {
@apply flex-col;
}
/* ---------------------------------------------------------------------------
Icon
--------------------------------------------------------------------------- */
.opal-content-heading-icon-container {
display: flex;
align-items: center;
justify-content: center;
}
.opal-content-heading-icon {
color: var(--text-04);
}
/* ---------------------------------------------------------------------------
Body column
--------------------------------------------------------------------------- */
.opal-content-heading-body {
@apply flex flex-1 flex-col items-start;
min-width: 0.0625rem;
}
/* ---------------------------------------------------------------------------
Title row — title (or input) + edit button
--------------------------------------------------------------------------- */
.opal-content-heading-title-row {
@apply flex items-center w-full;
gap: 0.25rem;
}
.opal-content-heading-title {
@apply text-left overflow-hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-heading-input {
@apply flex-1 bg-transparent outline-none border-none;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
/* ---------------------------------------------------------------------------
Edit button — visible only on hover of the outer container
--------------------------------------------------------------------------- */
.opal-content-heading-edit-button {
@apply opacity-0 transition-opacity shrink-0;
}
.opal-content-heading:hover .opal-content-heading-edit-button {
@apply opacity-100;
}
/* ---------------------------------------------------------------------------
Description
--------------------------------------------------------------------------- */
.opal-content-heading-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
Content — LabelLayout
Always inline (flex-row). Icon color varies per sizePreset and is applied
via Tailwind class from the component.
=========================================================================== */
/* ---------------------------------------------------------------------------
Layout
--------------------------------------------------------------------------- */
.opal-content-label {
@apply flex flex-row items-start;
}
/* ---------------------------------------------------------------------------
Icon
--------------------------------------------------------------------------- */
.opal-content-label-icon-container {
display: flex;
align-items: center;
justify-content: center;
}
/* ---------------------------------------------------------------------------
Body column
--------------------------------------------------------------------------- */
.opal-content-label-body {
@apply flex flex-1 flex-col items-start;
min-width: 0.0625rem;
}
/* ---------------------------------------------------------------------------
Title row — title (or input) + edit button
--------------------------------------------------------------------------- */
.opal-content-label-title-row {
@apply flex items-center w-full;
gap: 0.25rem;
}
.opal-content-label-title {
@apply text-left overflow-hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-label-input-sizer {
display: inline-grid;
align-items: stretch;
}
.opal-content-label-input-sizer > * {
grid-area: 1 / 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}
.opal-content-label-input-mirror {
visibility: hidden;
white-space: pre;
}
.opal-content-label-input {
@apply bg-transparent outline-none border-none;
}
/* ---------------------------------------------------------------------------
Aux icon
--------------------------------------------------------------------------- */
.opal-content-label-aux-icon {
display: flex;
align-items: center;
justify-content: center;
}
/* ---------------------------------------------------------------------------
Edit button — visible only on hover of the outer container
--------------------------------------------------------------------------- */
.opal-content-label-edit-button {
@apply opacity-0 transition-opacity shrink-0;
}
.opal-content-label:hover .opal-content-label-edit-button {
@apply opacity-100;
}
/* ---------------------------------------------------------------------------
Description
--------------------------------------------------------------------------- */
.opal-content-label-description {
@apply text-left w-full;
padding: 0 0.125rem;
}
/* ===========================================================================
Content — BodyLayout
Three orientation modes (driven by orientation prop):
inline : flex-row — icon left, title right
vertical: flex-col — icon top, title below
reverse : flex-row-reverse — title left, icon right
Icon color is always text-03. Title color varies by prominence
(text-04 default, text-03 muted) and is applied via Tailwind class.
=========================================================================== */
/* ---------------------------------------------------------------------------
Layout — orientation
--------------------------------------------------------------------------- */
.opal-content-body {
@apply flex items-start;
}
.opal-content-body[data-orientation="inline"] {
@apply flex-row;
}
.opal-content-body[data-orientation="vertical"] {
@apply flex-col;
}
.opal-content-body[data-orientation="reverse"] {
@apply flex-row-reverse;
}
/* ---------------------------------------------------------------------------
Icon
--------------------------------------------------------------------------- */
.opal-content-body-icon-container {
display: flex;
align-items: center;
justify-content: center;
}
/* ---------------------------------------------------------------------------
Title
--------------------------------------------------------------------------- */
.opal-content-body-title {
@apply text-left overflow-hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
padding: 0 0.125rem;
min-width: 0.0625rem;
}

View File

@@ -0,0 +1,61 @@
import "@opal/components/tags/AuxiliaryTag/styles.css";
import type { IconFunctionComponent } from "@opal/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type AuxiliaryTagColor = "green" | "purple" | "blue" | "gray" | "amber";
interface AuxiliaryTagProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
/** Tag label text. */
title: string;
/** Color variant. Default: `"gray"`. */
color?: AuxiliaryTagColor;
}
// ---------------------------------------------------------------------------
// Color config
// ---------------------------------------------------------------------------
const COLOR_CONFIG: Record<AuxiliaryTagColor, { bg: string; text: string }> = {
green: { bg: "bg-theme-green-01", text: "text-theme-green-05" },
blue: { bg: "bg-theme-blue-05", text: "text-theme-blue-01" },
purple: { bg: "bg-theme-purple-05", text: "text-theme-purple-01" },
amber: { bg: "bg-theme-amber-05", text: "text-theme-amber-01" },
gray: { bg: "bg-background-tint-02", text: "text-text-03" },
};
// ---------------------------------------------------------------------------
// AuxiliaryTag
// ---------------------------------------------------------------------------
function AuxiliaryTag({
icon: Icon,
title,
color = "gray",
}: AuxiliaryTagProps) {
const config = COLOR_CONFIG[color];
return (
<div className={`opal-auxiliary-tag ${config.bg}`}>
{Icon && (
<div className="opal-auxiliary-tag-icon-container">
<Icon className={`opal-auxiliary-tag-icon ${config.text}`} />
</div>
)}
<span
className={`opal-auxiliary-tag-title font-figure-small-value ${config.text}`}
>
{title}
</span>
</div>
);
}
export { AuxiliaryTag, type AuxiliaryTagProps, type AuxiliaryTagColor };

View File

@@ -0,0 +1,30 @@
/* ---------------------------------------------------------------------------
AuxiliaryTag
Fixed height of 1rem (16px). Icon is 0.75rem (12px) with p-0.5 (2px)
padding to match the font-figure-small-value line-height (12px).
--------------------------------------------------------------------------- */
.opal-auxiliary-tag {
@apply flex flex-row items-center shrink-0;
height: 1rem;
border-radius: 0.25rem;
padding: 0 0.25rem;
gap: 0.125rem;
}
.opal-auxiliary-tag-icon-container {
display: flex;
align-items: center;
justify-content: center;
padding: 0.125rem;
}
.opal-auxiliary-tag-icon {
width: 0.75rem;
height: 0.75rem;
}
.opal-auxiliary-tag-title {
white-space: nowrap;
}

View File

@@ -45,6 +45,7 @@ type InteractiveBaseVariantProps =
* - `"md"` — 1.75rem (28px), standard compact size
* - `"sm"` — 1.5rem (24px), for denser UIs
* - `"xs"` — 1.25rem (20px), for inline elements
* - `"2xs"` — 1rem (16px), for micro elements
* - `"fit"` — Shrink-wraps to content height (`h-fit`), for variable-height layouts
*/
type InteractiveContainerHeightVariant =
@@ -54,6 +55,7 @@ const interactiveContainerHeightVariants = {
md: "h-[1.75rem]",
sm: "h-[1.5rem]",
xs: "h-[1.25rem]",
"2xs": "h-[1rem]",
fit: "h-fit",
} as const;
const interactiveContainerMinWidthVariants = {
@@ -61,6 +63,7 @@ const interactiveContainerMinWidthVariants = {
md: "min-w-[1.75rem]",
sm: "min-w-[1.5rem]",
xs: "min-w-[1.25rem]",
"2xs": "min-w-[1rem]",
fit: "",
} as const;
const interactiveContainerPaddingVariants = {
@@ -68,7 +71,8 @@ const interactiveContainerPaddingVariants = {
md: "p-1",
sm: "p-1",
xs: "p-0.5",
fit: "",
"2xs": "p-0.5",
fit: "p-0",
} as const;
/**
@@ -82,6 +86,7 @@ type InteractiveContainerRoundingVariant =
const interactiveContainerRoundingVariants = {
default: "rounded-12",
compact: "rounded-08",
mini: "rounded-04",
} as const;
// ---------------------------------------------------------------------------
@@ -354,6 +359,7 @@ interface InteractiveContainerProps
* - `"md"` — 1.75rem (28px), standard compact size
* - `"sm"` — 1.5rem (24px), for denser UIs
* - `"xs"` — 1.25rem (20px), for inline elements
* - `"2xs"` — 1rem (16px), for micro elements
* - `"fit"` — Shrink-wraps to content height (`h-fit`)
*
* @default "lg"
@@ -377,7 +383,7 @@ interface InteractiveContainerProps
* // Standard card-like container
* <Interactive.Base>
* <Interactive.Container border>
* <LineItemLayout icon={SvgIcon} title="Option" />
* <Content icon={SvgIcon} title="Option" />
* </Interactive.Container>
* </Interactive.Base>
*

View File

@@ -136,25 +136,64 @@
--purple-05: #f1ebfa;
--purple-01: #f9f7fd;
/* Neon Scale */
/* Neon Scale
Base vars (--neon-X) are the /40 level.
Alpha variants use -aXX suffix (e.g. -a60 = 40 at 60% opacity).
Numeric suffixes are Figma scale levels (e.g. -50 = Neon/X/50). */
--neon-yellow-90: #5a581d;
--neon-yellow-80: #979430;
--neon-yellow-50: #ece600;
--neon-yellow: #fef800;
--neon-yellow-60: #fef80099;
--neon-yellow-30: #fef8004d;
--neon-yellow-a60: #fef80099;
--neon-yellow-a30: #fef8004d;
--neon-yellow-20: #fcfa8f;
--neon-yellow-05: #f9faeb;
--neon-amber-90: #625025;
--neon-amber-80: #a68018;
--neon-amber-60: #d9a500;
--neon-amber-50: #ecb400;
--neon-amber: #ffc733;
--neon-amber-60: #ffc73399;
--neon-amber-30: #ffc7334d;
--neon-amber-a60: #ffc73399;
--neon-amber-a30: #ffc7334d;
--neon-amber-20: #ffd985;
--neon-amber-05: #fef8ea;
--neon-sky-90: #204f67;
--neon-sky-80: #3989b3;
--neon-sky-50: #1ebcff;
--neon-sky: #4dc3ff;
--neon-sky-60: #4dc3ff99;
--neon-sky-30: #4dc3ff4d;
--neon-sky-a60: #4dc3ff99;
--neon-sky-a30: #4dc3ff4d;
--neon-sky-20: #93d8ff;
--neon-sky-05: #f2faff;
--neon-cyan-90: #1a5e5d;
--neon-cyan-80: #009a99;
--neon-cyan-50: #00ebea;
--neon-cyan: #00f9f9;
--neon-cyan-60: #00f9f999;
--neon-cyan-30: #00f9f94d;
--neon-cyan-a60: #00f9f999;
--neon-cyan-a30: #00f9f94d;
--neon-cyan-20: #62fefd;
--neon-cyan-05: #eafdfc;
--neon-lime-90: #3f5b39;
--neon-lime-80: #639e56;
--neon-lime-60: #53cd32;
--neon-lime: #6dff46;
--neon-lime-60: #6dff4699;
--neon-lime-30: #6dff464d;
--neon-lime-a60: #6dff4699;
--neon-lime-a30: #6dff464d;
--neon-lime-20: #a8ff94;
--neon-lime-05: #f2fcf0;
--neon-magenta-90: #654666;
--neon-magenta-80: #ab6bac;
--neon-magenta-50: #f198f2;
--neon-magenta: #fea1ff;
--neon-magenta-60: #fea1ff99;
--neon-magenta-30: #fea1ff4d;
--neon-magenta-a60: #fea1ff99;
--neon-magenta-a30: #fea1ff4d;
--neon-magenta-20: #fec4fe;
--neon-magenta-05: #fff5ff;
/* Stone Scale */
--stone-98: #0b0b0f;
@@ -254,8 +293,8 @@
--background-neutral-02: var(--grey-06);
--background-neutral-03: var(--grey-10);
--background-neutral-04: var(--grey-20);
--background-neutral-inverted-04: var(--grey-75);
--background-neutral-inverted-03: var(--grey-80);
--background-neutral-inverted-04: var(--grey-60);
--background-neutral-inverted-03: var(--grey-75);
--background-neutral-inverted-02: var(--grey-85);
--background-neutral-inverted-01: var(--grey-90);
--background-neutral-inverted-00: var(--grey-100);
@@ -269,7 +308,7 @@
--background-tint-02: var(--tint-05);
--background-tint-03: var(--tint-10);
--background-tint-04: var(--tint-20);
--background-tint-inverted-04: var(--tint-80);
--background-tint-inverted-04: var(--tint-60);
--background-tint-inverted-03: var(--tint-85);
--background-tint-inverted-02: var(--tint-90);
--background-tint-inverted-01: var(--tint-95);
@@ -296,7 +335,7 @@
/* Theme / Gradient */
--theme-gradient-05: var(--tint-50);
--theme-gradient-00: var(--grey-00);
--theme-gradient-00: var(--grey-100);
/* Theme / Red */
--theme-red-05: var(--red-50);
@@ -311,15 +350,15 @@
--theme-orange-01: var(--orange-05);
/* Theme / Amber */
--theme-amber-05: var(--neon-amber);
--theme-amber-05: var(--neon-amber-50);
--theme-amber-04: var(--neon-amber);
--theme-amber-02: var(--neon-amber-30);
--theme-amber-01: var(--neon-amber-30);
--theme-amber-02: var(--neon-amber-20);
--theme-amber-01: var(--neon-amber-05);
/* Theme / Yellow */
--theme-yellow-05: var(--neon-yellow);
--theme-yellow-02: var(--neon-yellow-30);
--theme-yellow-01: var(--neon-yellow-30);
--theme-yellow-05: var(--neon-yellow-50);
--theme-yellow-02: var(--neon-yellow-20);
--theme-yellow-01: var(--neon-yellow-05);
/* Theme / Green */
--theme-green-05: var(--green-60);
@@ -328,18 +367,18 @@
/* Theme / Lime */
--theme-lime-05: var(--neon-lime-60);
--theme-lime-02: var(--neon-lime-30);
--theme-lime-01: var(--neon-lime-30);
--theme-lime-02: var(--neon-lime-20);
--theme-lime-01: var(--neon-lime-05);
/* Theme / Cyan */
--theme-cyan-05: var(--neon-cyan);
--theme-cyan-02: var(--neon-cyan-30);
--theme-cyan-01: var(--neon-cyan-30);
--theme-cyan-05: var(--neon-cyan-50);
--theme-cyan-02: var(--neon-cyan-20);
--theme-cyan-01: var(--neon-cyan-05);
/* Theme / Sky */
--theme-sky-05: var(--neon-sky);
--theme-sky-02: var(--neon-sky-30);
--theme-sky-01: var(--neon-sky-30);
--theme-sky-05: var(--neon-sky-50);
--theme-sky-02: var(--neon-sky-20);
--theme-sky-01: var(--neon-sky-05);
/* Theme / Blue */
--theme-blue-05: var(--blue-50);
@@ -352,9 +391,9 @@
--theme-purple-01: var(--purple-05);
/* Theme / Magenta */
--theme-magenta-05: var(--neon-magenta);
--theme-magenta-02: var(--neon-magenta-30);
--theme-magenta-01: var(--neon-magenta-30);
--theme-magenta-05: var(--neon-magenta-50);
--theme-magenta-02: var(--neon-magenta-20);
--theme-magenta-01: var(--neon-magenta-05);
/* Status */
--status-success-05: var(--green-50);
@@ -408,10 +447,10 @@
--code-definition: var(--orange-55);
/* Highlight */
--highlight-match: var(--neon-yellow-30);
--highlight-selection: var(--neon-sky-30);
--highlight-active: var(--neon-amber-60);
--highlight-accent: var(--neon-magenta-60);
--highlight-match: var(--neon-yellow-a30);
--highlight-selection: var(--neon-sky-a30);
--highlight-active: var(--neon-amber-a60);
--highlight-accent: var(--neon-magenta-a60);
/* Shadow */
--shadow-01: var(--alpha-grey-100-05);
@@ -419,7 +458,7 @@
--shadow-03: var(--alpha-grey-100-20);
/* Mask */
--mask-01: var(--alpha-grey-100-10);
--mask-01: var(--alpha-grey-00-10);
--mask-02: var(--alpha-grey-100-20);
--mask-03: var(--alpha-grey-100-40);
@@ -514,13 +553,13 @@
/* Theme / Amber */
--theme-amber-05: var(--neon-amber);
--theme-amber-04: var(--neon-amber-60);
--theme-amber-02: var(--neon-amber-60);
--theme-amber-01: var(--neon-amber-60);
--theme-amber-02: var(--neon-amber-80);
--theme-amber-01: var(--neon-amber-90);
/* Theme / Yellow */
--theme-yellow-05: var(--neon-yellow);
--theme-yellow-02: var(--neon-yellow-60);
--theme-yellow-01: var(--neon-yellow-60);
--theme-yellow-02: var(--neon-yellow-80);
--theme-yellow-01: var(--neon-yellow-90);
/* Theme / Green */
--theme-green-05: var(--green-50);
@@ -529,18 +568,18 @@
/* Theme / Lime */
--theme-lime-05: var(--neon-lime);
--theme-lime-02: var(--neon-lime-60);
--theme-lime-01: var(--neon-lime-60);
--theme-lime-02: var(--neon-lime-80);
--theme-lime-01: var(--neon-lime-90);
/* Theme / Cyan */
--theme-cyan-05: var(--neon-cyan);
--theme-cyan-02: var(--neon-cyan-60);
--theme-cyan-01: var(--neon-cyan-60);
--theme-cyan-02: var(--neon-cyan-80);
--theme-cyan-01: var(--neon-cyan-90);
/* Theme / Sky */
--theme-sky-05: var(--neon-sky);
--theme-sky-02: var(--neon-sky-60);
--theme-sky-01: var(--neon-sky-60);
--theme-sky-02: var(--neon-sky-80);
--theme-sky-01: var(--neon-sky-90);
/* Theme / Blue */
--theme-blue-05: var(--blue-45);
@@ -554,8 +593,8 @@
/* Theme / Magenta */
--theme-magenta-05: var(--neon-magenta);
--theme-magenta-02: var(--neon-magenta-60);
--theme-magenta-01: var(--neon-magenta-60);
--theme-magenta-02: var(--neon-magenta-80);
--theme-magenta-01: var(--neon-magenta-90);
/* Status */
--status-success-05: var(--green-50);
@@ -609,10 +648,10 @@
--code-definition: var(--orange-50);
/* Highlight */
--highlight-match: var(--neon-yellow-30);
--highlight-selection: var(--neon-sky-30);
--highlight-active: var(--neon-amber-60);
--highlight-accent: var(--neon-magenta-60);
--highlight-match: var(--neon-yellow-a30);
--highlight-selection: var(--neon-sky-a30);
--highlight-active: var(--neon-amber-a60);
--highlight-accent: var(--neon-magenta-a60);
/* Shadow */
--shadow-01: var(--alpha-grey-00-05);
@@ -620,7 +659,7 @@
--shadow-03: var(--alpha-grey-00-20);
/* Mask */
--mask-01: var(--alpha-grey-100-10);
--mask-01: var(--alpha-grey-00-10);
--mask-02: var(--alpha-grey-100-20);
--mask-03: var(--alpha-grey-100-40);

View File

@@ -0,0 +1,557 @@
"use client";
import { AuxiliaryTag, Content } from "@opal/components";
import SvgSearch from "@opal/icons/search";
import SvgStar from "@opal/icons/star";
export default function StorybookPage() {
return (
<div style={{ padding: "2rem", maxWidth: 960, margin: "0 auto" }}>
<h1 style={{ fontSize: 28, fontWeight: 700, marginBottom: "2rem" }}>
Content Storybook
</h1>
{/* ================================================================= */}
{/* AuxiliaryTag */}
{/* ================================================================= */}
<LayoutGroup label="AuxiliaryTag">
<Section label="colors">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<AuxiliaryTag title="Green" color="green" />
<AuxiliaryTag title="Blue" color="blue" />
<AuxiliaryTag title="Purple" color="purple" />
<AuxiliaryTag title="Amber" color="amber" />
<AuxiliaryTag title="Gray" color="gray" />
</div>
</Section>
<Section label="with icon">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<AuxiliaryTag icon={SvgStar} title="Green" color="green" />
<AuxiliaryTag icon={SvgStar} title="Blue" color="blue" />
<AuxiliaryTag icon={SvgStar} title="Purple" color="purple" />
<AuxiliaryTag icon={SvgStar} title="Amber" color="amber" />
<AuxiliaryTag icon={SvgStar} title="Gray" color="gray" />
</div>
</Section>
<Section label="icon only">
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<AuxiliaryTag icon={SvgStar} title="" color="green" />
<AuxiliaryTag icon={SvgStar} title="" color="blue" />
</div>
</Section>
</LayoutGroup>
{/* ================================================================= */}
{/* HeadingLayout */}
{/* ================================================================= */}
<LayoutGroup label="HeadingLayout">
{/* ── headline + heading ── */}
<Section label="headline / heading (icon top)">
<Content
icon={SvgSearch}
sizePreset="headline"
variant="heading"
title="Headline Heading"
description="Icon placed above the content"
/>
<Content
icon={SvgSearch}
sizePreset="headline"
variant="heading"
title="Click edit to rename"
description="Editable headline with icon on top"
editable
onTitleChange={(v) => console.log("title changed:", v)}
/>
</Section>
{/* ── headline + section ── */}
<Section label="headline / section (icon inline)">
<Content
icon={SvgSearch}
sizePreset="headline"
variant="section"
title="Headline Section"
description="Icon placed inline with the content"
/>
<Content
icon={SvgSearch}
sizePreset="headline"
variant="section"
title="Click edit to rename"
description="Editable headline with icon inline"
editable
onTitleChange={(v) => console.log("title changed:", v)}
/>
</Section>
{/* ── section + heading ── */}
<Section label="section / heading (icon top)">
<Content
icon={SvgSearch}
sizePreset="section"
variant="heading"
title="Section Heading"
description="Smaller preset, icon placed above the content"
/>
<Content
icon={SvgSearch}
sizePreset="section"
variant="heading"
title="Click edit to rename"
description="Editable section with icon on top"
editable
onTitleChange={(v) => console.log("title changed:", v)}
/>
</Section>
{/* ── section + section ── */}
<Section label="section / section (icon inline)">
<Content
icon={SvgSearch}
sizePreset="section"
variant="section"
title="Section Section"
description="Smaller preset, icon placed inline"
/>
<Content
icon={SvgSearch}
sizePreset="section"
variant="section"
title="Click edit to rename"
description="Editable section with icon inline"
editable
onTitleChange={(v) => console.log("title changed:", v)}
/>
</Section>
{/* ── optional props ── */}
<Section label="optional props">
<Content
sizePreset="headline"
variant="heading"
title="No icon"
description="Only title and description"
/>
<Content
icon={SvgSearch}
sizePreset="headline"
variant="heading"
title="No description"
/>
<Content sizePreset="headline" variant="heading" title="Title only" />
</Section>
</LayoutGroup>
{/* ================================================================= */}
{/* LabelLayout */}
{/* ================================================================= */}
<LayoutGroup label="LabelLayout">
{/* ── main-content ── */}
<Section label="main-content">
<Content
icon={SvgSearch}
sizePreset="main-content"
title="Main Content Label"
description="font-main-content-emphasis title, stroke-text-04 icon"
/>
<Content
icon={SvgSearch}
sizePreset="main-content"
title="Click edit to rename"
description="Editable main-content label"
editable
onTitleChange={(v) => console.log("title changed:", v)}
/>
</Section>
{/* ── main-ui ── */}
<Section label="main-ui">
<Content
icon={SvgSearch}
sizePreset="main-ui"
title="Main UI Label"
description="font-main-ui-action title, stroke-text-03 icon"
/>
<Content
icon={SvgSearch}
sizePreset="main-ui"
title="Click edit to rename"
description="Editable main-ui label"
editable
onTitleChange={(v) => console.log("title changed:", v)}
/>
</Section>
{/* ── secondary ── */}
<Section label="secondary">
<Content
icon={SvgSearch}
sizePreset="secondary"
title="Secondary Label"
description="font-secondary-action title, stroke-text-04 icon"
/>
<Content
icon={SvgSearch}
sizePreset="secondary"
title="Click edit to rename"
description="Editable secondary label"
editable
onTitleChange={(v) => console.log("title changed:", v)}
/>
</Section>
{/* ── optional indicator ── */}
<Section label="optional indicator">
<Content
icon={SvgSearch}
sizePreset="main-content"
title="Main Content"
description="With (Optional) indicator"
optional
/>
<Content
icon={SvgSearch}
sizePreset="main-ui"
title="Main UI"
description="With (Optional) indicator"
optional
/>
<Content
icon={SvgSearch}
sizePreset="secondary"
title="Secondary"
description="With (Optional) indicator"
optional
/>
</Section>
{/* ── aux icon ── */}
<Section label="aux icon">
<Content
icon={SvgSearch}
sizePreset="main-ui"
title="Info gray"
description="Neutral informational icon"
auxIcon="info-gray"
/>
<Content
icon={SvgSearch}
sizePreset="main-ui"
title="Info blue"
description="Highlighted informational icon"
auxIcon="info-blue"
/>
<Content
icon={SvgSearch}
sizePreset="main-ui"
title="Warning"
description="Warning status icon"
auxIcon="warning"
/>
<Content
icon={SvgSearch}
sizePreset="main-ui"
title="Error"
description="Error status icon"
auxIcon="error"
/>
</Section>
<Section label="aux icon + optional + editable">
<Content
icon={SvgSearch}
sizePreset="main-content"
title="All accessories"
description="Optional + aux icon + editable"
optional
auxIcon="warning"
editable
onTitleChange={(v) => console.log("title changed:", v)}
/>
<Content
icon={SvgSearch}
sizePreset="secondary"
title="All accessories"
description="Optional + aux icon + editable at secondary"
optional
auxIcon="error"
editable
onTitleChange={(v) => console.log("title changed:", v)}
/>
</Section>
{/* ── optional props ── */}
<Section label="optional props">
<Content
sizePreset="main-ui"
title="No icon"
description="Only title and description"
/>
<Content
icon={SvgSearch}
sizePreset="main-ui"
title="No description"
/>
<Content sizePreset="main-ui" title="Title only" />
</Section>
</LayoutGroup>
{/* ================================================================= */}
{/* BodyLayout */}
{/* ================================================================= */}
<LayoutGroup label="BodyLayout">
{/* ── main-content ── */}
<Section label="main-content / inline">
<Content
icon={SvgSearch}
sizePreset="main-content"
variant="body"
orientation="inline"
title="Default inline"
/>
<Content
icon={SvgSearch}
sizePreset="main-content"
variant="body"
orientation="inline"
prominence="muted"
title="Muted inline"
/>
</Section>
<Section label="main-content / vertical">
<Content
icon={SvgSearch}
sizePreset="main-content"
variant="body"
orientation="vertical"
title="Default vertical"
/>
<Content
icon={SvgSearch}
sizePreset="main-content"
variant="body"
orientation="vertical"
prominence="muted"
title="Muted vertical"
/>
</Section>
<Section label="main-content / reverse">
<Content
icon={SvgSearch}
sizePreset="main-content"
variant="body"
orientation="reverse"
title="Default reverse"
/>
<Content
icon={SvgSearch}
sizePreset="main-content"
variant="body"
orientation="reverse"
prominence="muted"
title="Muted reverse"
/>
</Section>
{/* ── main-ui ── */}
<Section label="main-ui / inline">
<Content
icon={SvgSearch}
sizePreset="main-ui"
variant="body"
orientation="inline"
title="Default inline"
/>
<Content
icon={SvgSearch}
sizePreset="main-ui"
variant="body"
orientation="inline"
prominence="muted"
title="Muted inline"
/>
</Section>
<Section label="main-ui / vertical">
<Content
icon={SvgSearch}
sizePreset="main-ui"
variant="body"
orientation="vertical"
title="Default vertical"
/>
<Content
icon={SvgSearch}
sizePreset="main-ui"
variant="body"
orientation="vertical"
prominence="muted"
title="Muted vertical"
/>
</Section>
<Section label="main-ui / reverse">
<Content
icon={SvgSearch}
sizePreset="main-ui"
variant="body"
orientation="reverse"
title="Default reverse"
/>
<Content
icon={SvgSearch}
sizePreset="main-ui"
variant="body"
orientation="reverse"
prominence="muted"
title="Muted reverse"
/>
</Section>
{/* ── secondary ── */}
<Section label="secondary / inline">
<Content
icon={SvgSearch}
sizePreset="secondary"
variant="body"
orientation="inline"
title="Default inline"
/>
<Content
icon={SvgSearch}
sizePreset="secondary"
variant="body"
orientation="inline"
prominence="muted"
title="Muted inline"
/>
</Section>
<Section label="secondary / vertical">
<Content
icon={SvgSearch}
sizePreset="secondary"
variant="body"
orientation="vertical"
title="Default vertical"
/>
<Content
icon={SvgSearch}
sizePreset="secondary"
variant="body"
orientation="vertical"
prominence="muted"
title="Muted vertical"
/>
</Section>
<Section label="secondary / reverse">
<Content
icon={SvgSearch}
sizePreset="secondary"
variant="body"
orientation="reverse"
title="Default reverse"
/>
<Content
icon={SvgSearch}
sizePreset="secondary"
variant="body"
orientation="reverse"
prominence="muted"
title="Muted reverse"
/>
</Section>
{/* ── no icon ── */}
<Section label="no icon">
<Content
sizePreset="main-ui"
variant="body"
title="Title only (no icon)"
/>
<Content
sizePreset="main-ui"
variant="body"
prominence="muted"
title="Title only muted (no icon)"
/>
</Section>
</LayoutGroup>
</div>
);
}
// ---------------------------------------------------------------------------
// Storybook helpers
// ---------------------------------------------------------------------------
function LayoutGroup({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div
style={{
border: "2px solid #ccc",
borderRadius: 12,
padding: "1.5rem",
marginBottom: "2.5rem",
}}
>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: "1.5rem" }}>
{label}
</h2>
{children}
</div>
);
}
function Section({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<section style={{ marginBottom: "2rem" }}>
<h3
style={{
fontSize: 13,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: 1,
opacity: 0.5,
marginBottom: "0.75rem",
}}
>
{label}
</h3>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
border: "1px solid #e0e0e0",
borderRadius: 8,
padding: "1rem",
}}
>
{children}
</div>
</section>
);
}