From 910f1f76271abaa68c38c31a729b3c369453bf0d Mon Sep 17 00:00:00 2001 From: Lionel Chamorro Date: Fri, 19 Jun 2026 23:09:40 -0300 Subject: [PATCH] feat: add Voz a texto components + Storybook Pages workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the speech-to-text (Voz a texto) family extracted pixel-exact from the Figma UI Library: - AvatarPill (40002313:53080) — Avatar circle + name pill (default/selected) - Option (40001295:56887) — menu row with dismiss XCircle - CategoryItem (40001297:49803) — label row reusing Switch - TranscriptBlock (40002318:32859) — turn block, 4 states - SidePanel (40002322:53113) — composite assembling the above + TextField/Button Tokens: promote the Figma "bg/category/*" speaker palette to preset.ts and extend Avatar colours (violet/green/red/yellow/pink/orange/green-light). Vendor the Phosphor ArrowsMerge glyph (phosphor-react@1.4.1 predates it). CI: add pages.yml to build Storybook and deploy to GitHub Pages on push to main. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pages.yml | 46 ++++ docs/component-authoring.md | 6 + .../avatar-pill/AvatarPill.stories.tsx | 42 ++++ src/components/avatar-pill/AvatarPill.tsx | 73 ++++++ src/components/avatar-pill/index.ts | 2 + src/components/avatar/Avatar.tsx | 21 +- .../category-item/CategoryItem.stories.tsx | 51 ++++ src/components/category-item/CategoryItem.tsx | 59 +++++ src/components/category-item/index.ts | 2 + src/components/option/Option.stories.tsx | 52 +++++ src/components/option/Option.tsx | 89 +++++++ src/components/option/index.ts | 2 + src/components/side-panel/ArrowsMergeIcon.tsx | 32 +++ .../side-panel/SidePanel.stories.tsx | 51 ++++ src/components/side-panel/SidePanel.tsx | 221 ++++++++++++++++++ src/components/side-panel/index.ts | 2 + .../TranscriptBlock.stories.tsx | 69 ++++++ .../transcript-block/TranscriptBlock.tsx | 162 +++++++++++++ src/components/transcript-block/index.ts | 2 + src/index.ts | 7 + src/preset.ts | 11 + 21 files changed, 1001 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pages.yml create mode 100644 src/components/avatar-pill/AvatarPill.stories.tsx create mode 100644 src/components/avatar-pill/AvatarPill.tsx create mode 100644 src/components/avatar-pill/index.ts create mode 100644 src/components/category-item/CategoryItem.stories.tsx create mode 100644 src/components/category-item/CategoryItem.tsx create mode 100644 src/components/category-item/index.ts create mode 100644 src/components/option/Option.stories.tsx create mode 100644 src/components/option/Option.tsx create mode 100644 src/components/option/index.ts create mode 100644 src/components/side-panel/ArrowsMergeIcon.tsx create mode 100644 src/components/side-panel/SidePanel.stories.tsx create mode 100644 src/components/side-panel/SidePanel.tsx create mode 100644 src/components/side-panel/index.ts create mode 100644 src/components/transcript-block/TranscriptBlock.stories.tsx create mode 100644 src/components/transcript-block/TranscriptBlock.tsx create mode 100644 src/components/transcript-block/index.ts diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..0c13d28 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,46 @@ +name: Deploy Storybook to Pages + +# Builds the Storybook static site and publishes it to GitHub Pages on every +# push to main. Enable once in Settings → Pages → Source: "GitHub Actions". +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; don't cancel an in-progress one. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + # Generate the Panda styled-system the stories import from. + - run: pnpm codegen + - run: pnpm build-storybook + - uses: actions/upload-pages-artifact@v3 + with: + path: storybook-static + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/component-authoring.md b/docs/component-authoring.md index 626b3fc..86ace5b 100644 --- a/docs/component-authoring.md +++ b/docs/component-authoring.md @@ -47,6 +47,7 @@ import { hstack, stack } from "@/styled/patterns"; - colors: `brand.*`, `text.{default,lighter,onbutton-default,onbutton-alternative,onbutton-disabled}`, `action.{default,disabled,alt-default,hover,pressed,focus}`, `bg.{primary,secondary,primary-alternative,primary-highlight,secondary-highlight}`, + `category.{violet,green,red,yellow,pink,orange,green-light}` (speaker palette), `system.{success,error,warning,info}` (+ `-secondary`). - radii (px-accurate to Figma): `xs`=2, `sm`=4, `md`=8, `lg`=16, `xl`=24, `full`. - textStyles: `title.md`, `subtitle.{md,sm}`, `paragraph.{md,sm,xsm}`, @@ -94,6 +95,11 @@ a single variant symbol id, not the whole family frame. | CheckCircle | `1568:25593` | | | Logo | `40000499:40071` | Logo / Logo+Feature / Iso | | Tag | `40000041:10589` | Persona/CUIJ/Num_Expediente/Num_Actuacion/Fecha | +| AvatarPill ✅ | `40002313:53080` | Default/Selected; reuses Avatar + name | +| Option ✅ | `40001295:56887` | menu/list row: label + X; selected bg | +| Category Item ✅ | `40001297:49803` | label + Switch row | +| TranscriptBlock ✅ | `40002318:32859` | Voz a texto turn: Default/Select/Select+Hover/Typed | +| Side Panel (Voz a texto) ✅ | `40002322:53113` | composite: turno + personas sugeridas + marca de tiempo + acciones | ## Verify diff --git a/src/components/avatar-pill/AvatarPill.stories.tsx b/src/components/avatar-pill/AvatarPill.stories.tsx new file mode 100644 index 0000000..0fe8ff1 --- /dev/null +++ b/src/components/avatar-pill/AvatarPill.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AvatarPill } from "./AvatarPill"; + +const meta = { + title: "Components/AvatarPill", + component: AvatarPill, + parameters: { + layout: "centered", + figma: { + url: "https://www.figma.com/design/2BahKpebYzaccFih0ZB79y?node-id=40002313:53080", + }, + }, + argTypes: { + selected: { control: "boolean" }, + color: { + control: "inline-radio", + options: ["primary", "secondary", "warning", "success"], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { initials: "AB", name: "Name Surname", selected: false }, +}; + +export const Selected: Story = { + args: { initials: "AB", name: "Name Surname", selected: true }, +}; + +export const Matrix: Story = { + render: () => ( +
+ + + + +
+ ), +}; diff --git a/src/components/avatar-pill/AvatarPill.tsx b/src/components/avatar-pill/AvatarPill.tsx new file mode 100644 index 0000000..187fc5d --- /dev/null +++ b/src/components/avatar-pill/AvatarPill.tsx @@ -0,0 +1,73 @@ +import { type RecipeVariantProps, cva, cx } from "@/styled/css"; +import type { HTMLAttributes } from "react"; +import { Avatar, type AvatarColor } from "../avatar"; + +/** + * AvatarPill — speaker chip used in the Voz a texto (speech-to-text) side panel. + * AymurAI UI Library node 40002313:53080. + * + * Reuses {@link Avatar} (24px circle) plus a name label inside a rounded pill. + * Two states: Default (white bg, lighter name) and Selected (primary-alternative + * bg, default name). The avatar circle colour is per-speaker (passthrough). + */ +const pillRoot = cva({ + base: { + display: "inline-flex", + alignItems: "center", + gap: "2", // 8px + p: "2", // 8px + rounded: "xl", // 24px + cursor: "default", + userSelect: "none", + }, + variants: { + selected: { + true: { bg: "bg.primary-alternative" }, // #E5E8FF + false: { bg: "bg.secondary" }, // #FFFFFF + }, + }, + defaultVariants: { selected: false }, +}); + +const pillName = cva({ + base: { + textStyle: "label.md.default", // Archivo 16px + whiteSpace: "nowrap", + flexShrink: "0", + }, + variants: { + selected: { + true: { color: "text.default" }, // #110041 + false: { color: "text.lighter" }, // #625C68 + }, + }, + defaultVariants: { selected: false }, +}); + +export type AvatarPillProps = RecipeVariantProps & { + /** Initials shown inside the avatar circle (e.g. "AB") */ + initials: string; + /** Speaker name shown next to the avatar */ + name: string; + /** Avatar circle colour (per-speaker) */ + color?: AvatarColor; + className?: string; +} & Omit, "color">; + +export function AvatarPill({ + initials, + name, + selected, + color = "primary", + className, + ...props +}: AvatarPillProps) { + return ( + + + {name} + + ); +} + +export default AvatarPill; diff --git a/src/components/avatar-pill/index.ts b/src/components/avatar-pill/index.ts new file mode 100644 index 0000000..e86c55a --- /dev/null +++ b/src/components/avatar-pill/index.ts @@ -0,0 +1,2 @@ +export { AvatarPill, default } from "./AvatarPill"; +export type { AvatarPillProps } from "./AvatarPill"; diff --git a/src/components/avatar/Avatar.tsx b/src/components/avatar/Avatar.tsx index 3ac71fd..953f6db 100644 --- a/src/components/avatar/Avatar.tsx +++ b/src/components/avatar/Avatar.tsx @@ -29,12 +29,31 @@ const avatar = cva({ secondary: { bg: "bg.secondary-highlight" }, warning: { bg: "system.warning-secondary" }, success: { bg: "system.success-secondary" }, + // Speaker palette (Figma "bg/category/*"). + violet: { bg: "category.violet" }, + green: { bg: "category.green" }, + red: { bg: "category.red" }, + yellow: { bg: "category.yellow" }, + pink: { bg: "category.pink" }, + orange: { bg: "category.orange" }, + "green-light": { bg: "category.green-light" }, }, }, defaultVariants: { size: "sm", color: "primary" }, }); -export type AvatarColor = "primary" | "secondary" | "warning" | "success"; +export type AvatarColor = + | "primary" + | "secondary" + | "warning" + | "success" + | "violet" + | "green" + | "red" + | "yellow" + | "pink" + | "orange" + | "green-light"; export type AvatarProps = RecipeVariantProps & { /** Initials shown inside the circle (e.g. "AD") */ diff --git a/src/components/category-item/CategoryItem.stories.tsx b/src/components/category-item/CategoryItem.stories.tsx new file mode 100644 index 0000000..5a42ab5 --- /dev/null +++ b/src/components/category-item/CategoryItem.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { CategoryItem } from "./CategoryItem"; + +const meta = { + title: "Components/CategoryItem", + component: CategoryItem, + parameters: { + layout: "centered", + figma: { + url: "https://www.figma.com/design/2BahKpebYzaccFih0ZB79y?node-id=40001297:49803", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { label: "Categoria" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Checked: Story = { + args: { label: "Categoria", defaultChecked: true }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const List: Story = { + render: () => ( +
+ + + + +
+ ), +}; diff --git a/src/components/category-item/CategoryItem.tsx b/src/components/category-item/CategoryItem.tsx new file mode 100644 index 0000000..bb3c87e --- /dev/null +++ b/src/components/category-item/CategoryItem.tsx @@ -0,0 +1,59 @@ +import { css, cx } from "@/styled/css"; +import { Switch, type SwitchProps } from "../switch"; + +/** + * CategoryItem — a labelled row with a trailing toggle, used to enable/disable + * categories. AymurAI UI Library node 40001297:49803. + * + * Layout: bg.secondary (white), h 40px, px 16px / py 8px, rounded sm (4px), + * label on the left (label.md.default) and a {@link Switch} on the right. + */ +const root = css({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "2", + w: "full", + h: "[40px]", + px: "4", // 16px + py: "2", // 8px + rounded: "sm", // 4px + bg: "bg.secondary", + textStyle: "label.md.default", + color: "text.default", +}); + +export type CategoryItemProps = { + /** Row label */ + label: string; + className?: string; +} & Pick< + SwitchProps, + "checked" | "defaultChecked" | "onCheckedChange" | "disabled" | "name" +>; + +export function CategoryItem({ + label, + className, + checked, + defaultChecked, + onCheckedChange, + disabled, + name, +}: CategoryItemProps) { + return ( +
+ {label} + +
+ ); +} + +export default CategoryItem; diff --git a/src/components/category-item/index.ts b/src/components/category-item/index.ts new file mode 100644 index 0000000..d2c1341 --- /dev/null +++ b/src/components/category-item/index.ts @@ -0,0 +1,2 @@ +export { CategoryItem, default } from "./CategoryItem"; +export type { CategoryItemProps } from "./CategoryItem"; diff --git a/src/components/option/Option.stories.tsx b/src/components/option/Option.stories.tsx new file mode 100644 index 0000000..f932570 --- /dev/null +++ b/src/components/option/Option.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Option } from "./Option"; + +const meta = { + title: "Components/Option", + component: Option, + parameters: { + layout: "centered", + figma: { + url: "https://www.figma.com/design/2BahKpebYzaccFih0ZB79y?node-id=40001295:56887", + }, + }, + argTypes: { + selected: { control: "boolean" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { label: "Content", selected: false }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Selected: Story = { + args: { label: "Content", selected: true }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const List: Story = { + render: () => ( +
+
+ ), +}; diff --git a/src/components/option/Option.tsx b/src/components/option/Option.tsx new file mode 100644 index 0000000..dee9e2d --- /dev/null +++ b/src/components/option/Option.tsx @@ -0,0 +1,89 @@ +import { type RecipeVariantProps, css, cva, cx } from "@/styled/css"; +import { XCircle } from "phosphor-react"; +import type { HTMLAttributes, MouseEventHandler } from "react"; + +/** + * Option — selectable menu/list row used in the Voz a texto flows. + * AymurAI UI Library node 40001295:56887. + * + * Layout: h 32px, px 8px / py 6px, rounded sm (4px), label on the left and a + * dismiss XCircle on the right. Highlighted (selected/hover) uses + * bg.primary-alternative (#E5E8FF). + */ +const optionRoot = cva({ + base: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "2", + w: "full", + h: "[32px]", + px: "2", // 8px + py: "[6px]", + rounded: "sm", // 4px + textStyle: "label.md.default", + color: "text.default", + cursor: "pointer", + userSelect: "none", + bg: "[transparent]", + transitionProperty: "[background-color]", + transitionDuration: "fast", + transitionTimingFunction: "default", + "&:hover": { bg: "bg.primary-alternative" }, + }, + variants: { + selected: { + true: { bg: "bg.primary-alternative" }, // #E5E8FF + false: {}, + }, + }, + defaultVariants: { selected: false }, +}); + +const removeButton = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: "0", + p: "0", + borderWidth: "0", + bg: "[transparent]", + cursor: "pointer", + color: "[#343330]", // Figma icon colour "stone" — no semantic token + "&:focus-visible": { + outline: "primary-alt", + outlineWidth: "[2px]", + }, +}); + +export type OptionProps = RecipeVariantProps & { + /** Row label */ + label: string; + /** Called when the trailing XCircle is clicked */ + onRemove?: MouseEventHandler; + className?: string; +} & Omit, "onSelect">; + +export function Option({ + label, + selected, + onRemove, + className, + ...props +}: OptionProps) { + return ( +
+ {label} + +
+ ); +} + +export default Option; diff --git a/src/components/option/index.ts b/src/components/option/index.ts new file mode 100644 index 0000000..6f6b707 --- /dev/null +++ b/src/components/option/index.ts @@ -0,0 +1,2 @@ +export { Option, default } from "./Option"; +export type { OptionProps } from "./Option"; diff --git a/src/components/side-panel/ArrowsMergeIcon.tsx b/src/components/side-panel/ArrowsMergeIcon.tsx new file mode 100644 index 0000000..65c652a --- /dev/null +++ b/src/components/side-panel/ArrowsMergeIcon.tsx @@ -0,0 +1,32 @@ +import type { SVGProps } from "react"; + +/** + * ArrowsMerge — Phosphor "arrows-merge" glyph (UI Library node 40000451:88187). + * Vendored as an inline icon because the repo's `phosphor-react@1.4.1` predates + * this icon. Mirrors the phosphor API: `size` prop + `currentColor` fill. + * + * Base orientation points down (merge with next); rotate 180° to merge up. + */ +export function ArrowsMerge({ + size = 16, + ...props +}: { size?: number } & Omit, "width" | "height">) { + return ( + + ); +} + +export default ArrowsMerge; diff --git a/src/components/side-panel/SidePanel.stories.tsx b/src/components/side-panel/SidePanel.stories.tsx new file mode 100644 index 0000000..5582113 --- /dev/null +++ b/src/components/side-panel/SidePanel.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { SidePanel } from "./SidePanel"; + +const meta = { + title: "Components/SidePanel", + component: SidePanel, + parameters: { + layout: "centered", + figma: { + url: "https://www.figma.com/design/2BahKpebYzaccFih0ZB79y?node-id=40002322:53113", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const PEOPLE = [ + { initials: "AB", name: "Persona 1", color: "violet" as const }, + { initials: "AB", name: "Persona 2", color: "green" as const }, + { initials: "JU", name: "Jueza", color: "red" as const }, + { initials: "FI", name: "Fiscal", color: "yellow" as const }, + { initials: "DE", name: "Defensor", color: "pink" as const }, + { initials: "AB", name: "Imputado", color: "orange" as const }, + { initials: "DE", name: "Defensor", color: "green-light" as const }, +]; + +export const Default: Story = { + render: () => { + const [selected, setSelected] = useState(0); + const [time, setTime] = useState("01:15"); + return ( +
+ +
+ ); + }, +}; diff --git a/src/components/side-panel/SidePanel.tsx b/src/components/side-panel/SidePanel.tsx new file mode 100644 index 0000000..d619262 --- /dev/null +++ b/src/components/side-panel/SidePanel.tsx @@ -0,0 +1,221 @@ +import { css } from "@/styled/css"; +import { stack } from "@/styled/patterns"; +import { Plus, Trash } from "phosphor-react"; +import type { ReactNode } from "react"; +import type { AvatarColor } from "../avatar"; +import { Avatar } from "../avatar"; +import { AvatarPill } from "../avatar-pill"; +import { Button } from "../button"; +import { TextField } from "../text-field"; +import { ArrowsMerge } from "./ArrowsMergeIcon"; + +/** + * SidePanel — "Side Panel Voz a texto" (speech-to-text turn editor). + * AymurAI UI Library node 40002322:53113. + * + * Composite assembled from {@link AvatarPill}, {@link TextField} and + * {@link Button}. Sections: selected turn card, suggested people, timestamp, + * and turn actions (merge previous/next, add below, delete). + * + * Merge actions use the Phosphor "ArrowsMerge" glyph (vendored in + * ./ArrowsMergeIcon as phosphor-react@1.4.1 predates it): base points down + * ("siguiente"), rotated 180° points up ("anterior"). + */ +export type SidePanelPerson = { + initials: string; + name: string; + color?: AvatarColor; +}; + +export type SidePanelProps = { + /** The currently selected transcript turn */ + turn: { initials: string; name: string; time: string; color?: AvatarColor }; + /** Suggested speakers shown as pills */ + people: SidePanelPerson[]; + /** Index of the selected pill in `people` */ + selectedIndex?: number; + onSelectPerson?: (index: number) => void; + onNewPerson?: () => void; + /** Timestamp field value (e.g. "01:15") */ + timestamp: string; + onTimestampChange?: (value: string) => void; + onMergePrevious?: () => void; + onMergeNext?: () => void; + onAddBelow?: () => void; + onDelete?: () => void; + className?: string; +}; + +const root = css({ + display: "flex", + flexDirection: "column", + gap: "6", // 24px + pt: "[42px]", + px: "8", // 32px + pb: "8", + bg: "bg.primary", + w: "full", +}); + +const card = css({ + ...stack.raw({ gap: "1" }), // 4px + bg: "bg.secondary", + rounded: "md", // 8px + px: "4", // 16px + py: "3", // 12px + w: "full", +}); + +const cardTitle = css({ + textStyle: "subtitle.sm.default", + color: "text.default", +}); +const turnRow = css({ display: "flex", alignItems: "center", gap: "2" }); +const turnName = css({ + textStyle: "label.md.strong", + color: "text.lighter", + whiteSpace: "nowrap", +}); +const turnTime = css({ + textStyle: "label.md.default", + color: "text.lighter", + whiteSpace: "nowrap", +}); + +const sectionHeading = css({ + textStyle: "subtitle.md.strong", // Archivo SemiBold 20px + color: "text.default", +}); +const pills = css({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: "2", // 8px + w: "full", +}); +const actions = css({ ...stack.raw({ gap: "4" }), w: "full" }); // 16px +const fullWidthButton = css({ w: "full" }); +const divider = css({ + h: "[1px]", + w: "full", + bg: "[#BCBAB8]", // Figma divider line (border/primary colour) +}); + +function Section({ + heading, + children, +}: { + heading: string; + children: ReactNode; +}) { + return ( +
+

{heading}

+ {children} +
+ ); +} + +export function SidePanel({ + turn, + people, + selectedIndex, + onSelectPerson, + onNewPerson, + timestamp, + onTimestampChange, + onMergePrevious, + onMergeNext, + onAddBelow, + onDelete, + className, +}: SidePanelProps) { + return ( +
+ {/* Selected turn */} +
+

Turno seleccionado

+
+ + {turn.name} + {turn.time} +
+
+ + {/* Suggested people */} +
+
+ {people.map((person, index) => ( + onSelectPerson?.(index)} + /> + ))} + +
+
+
+ + {/* Timestamp */} +
+ onTimestampChange?.(e.target.value)} + aria-label="Marca de tiempo" + /> +
+
+ + {/* Actions */} +
+
+ + + + +
+
+
+ ); +} + +export default SidePanel; diff --git a/src/components/side-panel/index.ts b/src/components/side-panel/index.ts new file mode 100644 index 0000000..f55d242 --- /dev/null +++ b/src/components/side-panel/index.ts @@ -0,0 +1,2 @@ +export { SidePanel, default } from "./SidePanel"; +export type { SidePanelProps, SidePanelPerson } from "./SidePanel"; diff --git a/src/components/transcript-block/TranscriptBlock.stories.tsx b/src/components/transcript-block/TranscriptBlock.stories.tsx new file mode 100644 index 0000000..60d3ed8 --- /dev/null +++ b/src/components/transcript-block/TranscriptBlock.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { TranscriptBlock } from "./TranscriptBlock"; + +const SAMPLE = + "Estamos aquí reunidos en relación a un caso que tiene el número 78274, que es un caso que usted conoce, que la fiscalía está trabajando la investigación de ese caso, para lo cual estaba en principio prevista la discusión de los hechos como corresponde en un juicio oral y público."; + +const meta = { + title: "Components/TranscriptBlock", + component: TranscriptBlock, + parameters: { + layout: "padded", + figma: { + url: "https://www.figma.com/design/2BahKpebYzaccFih0ZB79y?node-id=40002318:32859", + }, + }, + argTypes: { + variant: { + control: "inline-radio", + options: ["default", "selected", "hover", "typed"], + }, + color: { + control: "inline-radio", + options: ["primary", "secondary", "warning", "success"], + }, + }, + args: { + initials: "AB", + name: "Persona1", + time: "01:15", + text: SAMPLE, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { args: { variant: "default" } }; +export const Selected: Story = { args: { variant: "selected" } }; +export const SelectHover: Story = { args: { variant: "hover" } }; +export const Typed: Story = { args: { variant: "typed" } }; + +export const Conversation: Story = { + render: () => ( +
+ + +
+ ), +}; diff --git a/src/components/transcript-block/TranscriptBlock.tsx b/src/components/transcript-block/TranscriptBlock.tsx new file mode 100644 index 0000000..9103a77 --- /dev/null +++ b/src/components/transcript-block/TranscriptBlock.tsx @@ -0,0 +1,162 @@ +import { type RecipeVariantProps, css, cva, cx } from "@/styled/css"; +import type { HTMLAttributes } from "react"; +import { Avatar, type AvatarColor } from "../avatar"; + +/** + * TranscriptBlock — a single speaker turn in the Voz a texto (speech-to-text) + * transcript. AymurAI UI Library node 40002318:32859. + * + * Header: avatar (24px) + speaker name (label.md.strong) + timestamp. + * Body: Archivo Light 16px / 26px line-height, text.default. + * + * Variants (Figma "Property 1"): + * - default no left bar; body padded px 32. + * - selected brand.primary left bar (4px); plain body. + * - hover selected + body bg primary-alternative @80% (rounded md). + * - typed selected + white body with border.primary (editing state). + */ +const transcriptRoot = cva({ + base: { display: "flex", w: "full" }, + variants: { + variant: { + default: { flexDirection: "column", gap: "3" }, // 12px + selected: { flexDirection: "row", gap: "[11px]", alignItems: "stretch" }, + hover: { flexDirection: "row", gap: "[11px]", alignItems: "stretch" }, + typed: { flexDirection: "row", gap: "[11px]", alignItems: "stretch" }, + }, + }, + defaultVariants: { variant: "default" }, +}); + +const bar = css({ + w: "[4px]", + flexShrink: "0", + alignSelf: "stretch", + bg: "brand.primary", // #3F479D + rounded: "lg", // 16px +}); + +const content = css({ + display: "flex", + flexDirection: "column", + gap: "3", // 12px + flex: "1", + minW: "0", +}); + +const header = css({ + display: "flex", + alignItems: "center", + gap: "[10px]", +}); + +const avatarName = css({ + display: "flex", + alignItems: "center", + gap: "2", // 8px +}); + +const speakerName = css({ + textStyle: "label.md.strong", // Archivo SemiBold 16px + color: "text.lighter", // #625C68 + whiteSpace: "nowrap", +}); + +const timestamp = css({ + textStyle: "label.md.default", + color: "text.lighter", + whiteSpace: "nowrap", +}); + +const transcriptBody = cva({ + base: { + fontFamily: "primary", // Archivo + fontWeight: "[300]", // Light + fontSize: "[16px]", + lineHeight: "[26px]", + color: "text.default", + w: "full", + }, + variants: { + variant: { + default: { px: "8" }, // 32px + selected: { pl: "4", pr: "8", py: "2" }, // pl16 pr32 py8 + hover: { + pl: "4", + pr: "8", + py: "2", + rounded: "md", // 8px + bg: "[rgba(229,232,255,0.8)]", // primary-alternative @ 80% + }, + typed: { + pl: "4", + pr: "8", + py: "2", + rounded: "md", + bg: "bg.secondary", + border: "primary", // 1px solid #BCBAB8 + }, + }, + }, + defaultVariants: { variant: "default" }, +}); + +export type TranscriptBlockProps = RecipeVariantProps & { + /** Speaker initials shown in the avatar */ + initials: string; + /** Speaker name */ + name: string; + /** Timestamp label (e.g. "01:15") */ + time: string; + /** Transcript text for this turn */ + text: string; + /** Avatar circle colour (per-speaker) */ + color?: AvatarColor; + className?: string; +} & Omit, "color">; + +export function TranscriptBlock({ + initials, + name, + time, + text, + variant = "default", + color = "primary", + className, + ...props +}: TranscriptBlockProps) { + const head = ( +
+
+ + {name} +
+ {time} +
+ ); + const body =

{text}

; + + if (variant === "default") { + return ( +
+ {head} + {body} +
+ ); + } + + return ( +
+
+
+ {head} + {body} +
+
+ ); +} + +export default TranscriptBlock; diff --git a/src/components/transcript-block/index.ts b/src/components/transcript-block/index.ts new file mode 100644 index 0000000..de9b6b2 --- /dev/null +++ b/src/components/transcript-block/index.ts @@ -0,0 +1,2 @@ +export { TranscriptBlock, default } from "./TranscriptBlock"; +export type { TranscriptBlockProps } from "./TranscriptBlock"; diff --git a/src/index.ts b/src/index.ts index a68b100..2de0bc1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ export * from "./components/stepper"; // Media & identity export * from "./components/avatar"; +export * from "./components/avatar-pill"; export * from "./components/player"; // Surfaces & overlays @@ -50,3 +51,9 @@ export * from "./components/logo"; // Archives export * from "./components/archives"; + +// Voz a texto (speech-to-text) +export * from "./components/option"; +export * from "./components/category-item"; +export * from "./components/transcript-block"; +export * from "./components/side-panel"; diff --git a/src/preset.ts b/src/preset.ts index dbb6816..9b34510 100644 --- a/src/preset.ts +++ b/src/preset.ts @@ -131,6 +131,17 @@ export const aymuraiPreset = definePreset({ "primary-highlight": color("#C5CAFF"), "secondary-highlight": color("#E0DDE2"), }, + // Speaker/category palette (Figma "bg/category/*") — distinguishes + // people in the Voz a texto flows (avatar fills, pills). + category: { + violet: color("#C5CAFF"), // bg/category/violet-ligth + green: color("#94FFC8"), // bg/category/green + red: color("#F69FA6"), // bg/category/red + yellow: color("#FDE27B"), // bg/category/yellow + pink: color("#F8AEEF"), // bg/category/pink + orange: color("#FFE2C4"), // bg/category/orange-ligth + "green-light": color("#D1F4E2"), // bg/category/green-ligth + }, system: { success: color("#1B834E"), "success-secondary": color("#E0FAED"),