Publish DevRunbook source
Managed validation / full (push) Successful in 3m18s

This commit is contained in:
DevRunbook release export
2026-09-03 04:09:17 +02:00
commit cfd2804e27
928 changed files with 161642 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@devrunbook/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "eslint src --max-warnings=0",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"react": "19.2.8"
},
"devDependencies": {
"@types/react": "19.2.8",
"typescript": "5.9.3",
"vitest": "4.1.10"
},
"peerDependencies": {
"react": "^19.2.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
export const productName = 'DevRunbook'
export * from './playbooks/playbook-card'
export * from './playbooks/playbook-dense-row'
export * from './playbooks/playbook-types'
export * from './primitives/button'
export * from './primitives/icon-button'
export * from './primitives/segmented-control'
export * from './primitives/skeleton'
export * from './primitives/surface'
export * from './status/badges'
export * from './status/state-panel'
+5
View File
@@ -0,0 +1,5 @@
export function classNames(
...values: readonly (string | false | null | undefined)[]
): string {
return values.filter((value): value is string => Boolean(value)).join(' ')
}
+160
View File
@@ -0,0 +1,160 @@
import { classNames } from '../lib/class-names'
import { LifecycleBadge, RiskBadge, SourceBadge } from '../status/badges'
import type {
PlaybookActions,
PlaybookLabels,
PlaybookPresentation,
} from './playbook-types'
export interface PlaybookCardProps
extends PlaybookPresentation, PlaybookActions {
readonly className?: string
readonly labels?: PlaybookLabels
}
export function PlaybookCard({
id,
slug,
title,
outcome,
category,
type,
riskTier,
autonomyMinimum,
autonomyMaximum,
defaultAutonomy,
tags,
lifecycle,
source,
version,
updatedAt,
favorite,
matchReasons,
detailHref,
composeHref,
favoriteBusy = false,
onFavoriteChange,
className,
labels,
}: PlaybookCardProps) {
const copy = labels ?? defaultLabels
const titleId = `playbook-${id}-title`
return (
<article
className={classNames('drb-playbook-card', className)}
aria-labelledby={titleId}
data-playbook-slug={slug}
>
<header className="drb-playbook-card__header">
<div className="drb-playbook-card__badges">
<LifecycleBadge value={lifecycle} />
<SourceBadge value={source} />
<RiskBadge value={riskTier} />
</div>
{onFavoriteChange ? (
<button
type="button"
className="drb-playbook-card__favorite"
aria-label={
favorite ? copy.removeFavorite(title) : copy.addFavorite(title)
}
aria-pressed={favorite}
aria-busy={favoriteBusy || undefined}
disabled={favoriteBusy}
onClick={() => onFavoriteChange(!favorite)}
>
<span aria-hidden="true">{favorite ? copy.saved : copy.save}</span>
</button>
) : null}
</header>
<div className="drb-playbook-card__identity">
<p className="drb-playbook-card__eyebrow">
{category} / {type}
</p>
<h2 className="drb-playbook-card__title" id={titleId}>
<a href={detailHref}>{title}</a>
</h2>
<p className="drb-playbook-card__outcome">{outcome}</p>
</div>
<dl className="drb-playbook-card__facts">
<div>
<dt>{copy.defaultAutonomy}</dt>
<dd>{defaultAutonomy}</dd>
</div>
{autonomyMinimum && autonomyMaximum ? (
<div>
<dt>{copy.supportedRange}</dt>
<dd>
{autonomyMinimum} {copy.rangeConnector} {autonomyMaximum}
</dd>
</div>
) : null}
</dl>
{tags.length > 0 ? (
<ul className="drb-playbook-card__tags" aria-label={copy.supportedTags}>
{tags.slice(0, 4).map((tag) => (
<li key={tag}>{tag}</li>
))}
</ul>
) : null}
{matchReasons && matchReasons.length > 0 ? (
<div className="drb-playbook-card__match">
<p>{copy.whyMatch}</p>
<ul>
{matchReasons.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
</div>
) : null}
<footer className="drb-playbook-card__footer">
<p className="drb-playbook-card__version">
<span>
{copy.version} {version}
</span>
{updatedAt ? <time dateTime={updatedAt}>{updatedAt}</time> : null}
</p>
<div className="drb-playbook-card__actions">
<a
className="drb-link-button drb-link-button--secondary"
href={detailHref}
>
{copy.review}
</a>
{composeHref ? (
<a
className="drb-link-button drb-link-button--primary"
href={composeHref}
>
{copy.compose}
</a>
) : null}
</div>
</footer>
</article>
)
}
const defaultLabels: PlaybookLabels = {
save: 'Save',
saved: 'Saved',
addFavorite: (title) => `Add ${title} to favorites`,
removeFavorite: (title) => `Remove ${title} from favorites`,
defaultAutonomy: 'Default autonomy',
supportedRange: 'Supported range',
rangeConnector: 'to',
supportedTags: 'Supported tags',
whyMatch: 'Why this matches',
version: 'Version',
autonomy: 'Autonomy',
updated: 'Updated',
status: 'Playbook status',
matches: 'Matches',
review: 'Review',
compose: 'Compose',
}
@@ -0,0 +1,137 @@
import { classNames } from '../lib/class-names'
import { LifecycleBadge, RiskBadge, SourceBadge } from '../status/badges'
import type {
PlaybookActions,
PlaybookLabels,
PlaybookPresentation,
} from './playbook-types'
export interface PlaybookDenseRowProps
extends PlaybookPresentation, PlaybookActions {
readonly className?: string
readonly labels?: PlaybookLabels
}
export function PlaybookDenseRow({
id,
slug,
title,
outcome,
category,
type,
riskTier,
defaultAutonomy,
lifecycle,
source,
version,
updatedAt,
favorite,
matchReasons,
detailHref,
composeHref,
favoriteBusy = false,
onFavoriteChange,
className,
labels,
}: PlaybookDenseRowProps) {
const copy = labels ?? defaultLabels
const titleId = `playbook-${id}-dense-title`
return (
<article
className={classNames('drb-playbook-dense-row', className)}
aria-labelledby={titleId}
data-playbook-slug={slug}
>
<div className="drb-playbook-dense-row__identity">
<p className="drb-playbook-dense-row__eyebrow">
{category} / {type}
</p>
<h2 id={titleId}>
<a href={detailHref}>{title}</a>
</h2>
<p>{outcome}</p>
{matchReasons && matchReasons.length > 0 ? (
<p className="drb-playbook-dense-row__match">
{copy.matches}: {matchReasons.join('; ')}
</p>
) : null}
</div>
<div className="drb-playbook-dense-row__badges" aria-label={copy.status}>
<LifecycleBadge value={lifecycle} />
<SourceBadge value={source} />
<RiskBadge value={riskTier} />
</div>
<dl className="drb-playbook-dense-row__facts">
<div>
<dt>{copy.autonomy}</dt>
<dd>{defaultAutonomy}</dd>
</div>
<div>
<dt>{copy.version}</dt>
<dd>{version}</dd>
</div>
{updatedAt ? (
<div>
<dt>{copy.updated}</dt>
<dd>
<time dateTime={updatedAt}>{updatedAt}</time>
</dd>
</div>
) : null}
</dl>
<div className="drb-playbook-dense-row__actions">
{onFavoriteChange ? (
<button
type="button"
className="drb-playbook-dense-row__favorite"
aria-label={
favorite ? copy.removeFavorite(title) : copy.addFavorite(title)
}
aria-pressed={favorite}
aria-busy={favoriteBusy || undefined}
disabled={favoriteBusy}
onClick={() => onFavoriteChange(!favorite)}
>
<span aria-hidden="true">{favorite ? copy.saved : copy.save}</span>
</button>
) : null}
<a
className="drb-link-button drb-link-button--secondary"
href={detailHref}
>
{copy.review}
</a>
{composeHref ? (
<a
className="drb-link-button drb-link-button--primary"
href={composeHref}
>
{copy.compose}
</a>
) : null}
</div>
</article>
)
}
const defaultLabels: PlaybookLabels = {
save: 'Save',
saved: 'Saved',
addFavorite: (title) => `Add ${title} to favorites`,
removeFavorite: (title) => `Remove ${title} from favorites`,
defaultAutonomy: 'Default autonomy',
supportedRange: 'Supported range',
rangeConnector: 'to',
supportedTags: 'Supported tags',
whyMatch: 'Why this matches',
version: 'Version',
autonomy: 'Autonomy',
updated: 'Updated',
status: 'Playbook status',
matches: 'Matches',
review: 'Review',
compose: 'Compose',
}
@@ -0,0 +1,47 @@
import type { Lifecycle, PlaybookSource, RiskTier } from '../status/badges'
export interface PlaybookPresentation {
readonly id: string
readonly slug: string
readonly title: string
readonly outcome: string
readonly category: string
readonly type: string
readonly riskTier: RiskTier
readonly autonomyMinimum?: string
readonly autonomyMaximum?: string
readonly defaultAutonomy: string
readonly tags: readonly string[]
readonly lifecycle: Lifecycle
readonly source: PlaybookSource
readonly version: string
readonly updatedAt?: string
readonly favorite: boolean
readonly matchReasons?: readonly string[]
}
export interface PlaybookActions {
readonly detailHref: string
readonly composeHref?: string
readonly favoriteBusy?: boolean
readonly onFavoriteChange?: (favorite: boolean) => void
}
export interface PlaybookLabels {
readonly save: string
readonly saved: string
readonly addFavorite: (title: string) => string
readonly removeFavorite: (title: string) => string
readonly defaultAutonomy: string
readonly supportedRange: string
readonly rangeConnector: string
readonly supportedTags: string
readonly whyMatch: string
readonly version: string
readonly autonomy: string
readonly updated: string
readonly status: string
readonly matches: string
readonly review: string
readonly compose: string
}
+54
View File
@@ -0,0 +1,54 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { classNames } from '../lib/class-names'
export type ButtonVariant = 'primary' | 'secondary' | 'quiet' | 'danger'
export type ButtonSize = 'small' | 'medium' | 'large'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
readonly variant?: ButtonVariant
readonly size?: ButtonSize
readonly leadingIcon?: ReactNode
readonly trailingIcon?: ReactNode
readonly busy?: boolean
}
export function Button({
variant = 'primary',
size = 'medium',
leadingIcon,
trailingIcon,
busy = false,
disabled,
className,
children,
type = 'button',
...props
}: ButtonProps) {
return (
<button
type={type}
className={classNames(
'drb-button',
`drb-button--${variant}`,
`drb-button--${size}`,
className,
)}
disabled={disabled || busy}
aria-busy={busy || undefined}
{...props}
>
{leadingIcon ? (
<span className="drb-button__icon" aria-hidden="true">
{leadingIcon}
</span>
) : null}
<span className="drb-button__label">{children}</span>
{trailingIcon ? (
<span className="drb-button__icon" aria-hidden="true">
{trailingIcon}
</span>
) : null}
</button>
)
}
@@ -0,0 +1,45 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { classNames } from '../lib/class-names'
import type { ButtonSize, ButtonVariant } from './button'
export interface IconButtonProps extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'children'
> {
readonly label: string
readonly icon: ReactNode
readonly variant?: ButtonVariant
readonly size?: ButtonSize
readonly busy?: boolean
}
export function IconButton({
label,
icon,
variant = 'quiet',
size = 'medium',
busy = false,
disabled,
className,
type = 'button',
...props
}: IconButtonProps) {
return (
<button
type={type}
className={classNames(
'drb-icon-button',
`drb-icon-button--${variant}`,
`drb-icon-button--${size}`,
className,
)}
aria-label={label}
aria-busy={busy || undefined}
disabled={disabled || busy}
{...props}
>
<span aria-hidden="true">{icon}</span>
</button>
)
}
@@ -0,0 +1,50 @@
import { classNames } from '../lib/class-names'
export interface SegmentedControlOption<Value extends string> {
readonly value: Value
readonly label: string
readonly disabled?: boolean
}
export interface SegmentedControlProps<Value extends string> {
readonly legend: string
readonly name: string
readonly value: Value
readonly options: readonly SegmentedControlOption<Value>[]
readonly onValueChange?: (value: Value) => void
readonly className?: string
readonly disabled?: boolean
}
export function SegmentedControl<Value extends string>({
legend,
name,
value,
options,
onValueChange,
className,
disabled = false,
}: SegmentedControlProps<Value>) {
return (
<fieldset
className={classNames('drb-segmented-control', className)}
disabled={disabled}
>
<legend className="drb-visually-hidden">{legend}</legend>
{options.map((option) => (
<label className="drb-segmented-control__option" key={option.value}>
<input
className="drb-segmented-control__input"
type="radio"
name={name}
value={option.value}
checked={option.value === value}
disabled={option.disabled}
onChange={() => onValueChange?.(option.value)}
/>
<span className="drb-segmented-control__label">{option.label}</span>
</label>
))}
</fieldset>
)
}
+25
View File
@@ -0,0 +1,25 @@
import type { HTMLAttributes } from 'react'
import { classNames } from '../lib/class-names'
export interface SkeletonProps extends HTMLAttributes<HTMLSpanElement> {
readonly shape?: 'text' | 'rectangle' | 'circle'
}
export function Skeleton({
shape = 'text',
className,
...props
}: SkeletonProps) {
return (
<span
className={classNames(
'drb-skeleton',
`drb-skeleton--${shape}`,
className,
)}
{...props}
aria-hidden="true"
/>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { HTMLAttributes, ReactNode } from 'react'
import { classNames } from '../lib/class-names'
export type SurfaceElement = 'div' | 'section' | 'article' | 'aside'
export type SurfaceElevation = 'flat' | 'raised' | 'overlay'
export interface SurfaceProps extends HTMLAttributes<HTMLElement> {
readonly as?: SurfaceElement
readonly elevation?: SurfaceElevation
readonly children: ReactNode
}
export function Surface({
as: Component = 'div',
elevation = 'flat',
className,
children,
...props
}: SurfaceProps) {
return (
<Component
className={classNames(
'drb-surface',
`drb-surface--${elevation}`,
className,
)}
{...props}
>
{children}
</Component>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { getLifecycleLabel, getRiskLabel, getSourceLabel } from './badges'
describe('badge labels', () => {
it('provides complete human-readable labels for terse catalog values', () => {
expect(getRiskLabel('moderate')).toBe('Moderate risk')
expect(getLifecycleLabel('battle-tested')).toBe('Battle-tested')
expect(getSourceLabel('built_in')).toBe('Built-in')
})
})
+95
View File
@@ -0,0 +1,95 @@
import type { HTMLAttributes } from 'react'
import { classNames } from '../lib/class-names'
export type RiskTier = 'low' | 'moderate' | 'high' | 'critical'
export type Lifecycle =
'draft' | 'reviewed' | 'validated' | 'battle-tested' | 'deprecated'
export type PlaybookSource = 'built_in' | 'private' | 'imported'
const riskLabels: Readonly<Record<RiskTier, string>> = {
low: 'Low risk',
moderate: 'Moderate risk',
high: 'High risk',
critical: 'Critical risk',
}
const lifecycleLabels: Readonly<Record<Lifecycle, string>> = {
draft: 'Draft',
reviewed: 'Reviewed',
validated: 'Validated',
'battle-tested': 'Battle-tested',
deprecated: 'Deprecated',
}
const sourceLabels: Readonly<Record<PlaybookSource, string>> = {
built_in: 'Built-in',
private: 'Private',
imported: 'Imported',
}
export function getRiskLabel(value: RiskTier): string {
return riskLabels[value]
}
export function getLifecycleLabel(value: Lifecycle): string {
return lifecycleLabels[value]
}
export function getSourceLabel(value: PlaybookSource): string {
return sourceLabels[value]
}
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
readonly value: string
readonly label: string
readonly kind: 'risk' | 'lifecycle' | 'source'
}
function Badge({ value, label, kind, className, ...props }: BadgeProps) {
return (
<span
className={classNames(
'drb-badge',
`drb-badge--${kind}`,
`drb-badge--${value.replaceAll('_', '-')}`,
className,
)}
{...props}
>
{label}
</span>
)
}
export function RiskBadge({
value,
...props
}: { readonly value: RiskTier } & Omit<BadgeProps, 'kind' | 'label'>) {
return (
<Badge {...props} kind="risk" value={value} label={riskLabels[value]} />
)
}
export function LifecycleBadge({
value,
...props
}: { readonly value: Lifecycle } & Omit<BadgeProps, 'kind' | 'label'>) {
return (
<Badge
{...props}
kind="lifecycle"
value={value}
label={lifecycleLabels[value]}
/>
)
}
export function SourceBadge({
value,
...props
}: { readonly value: PlaybookSource } & Omit<BadgeProps, 'kind' | 'label'>) {
return (
<Badge {...props} kind="source" value={value} label={sourceLabels[value]} />
)
}
+52
View File
@@ -0,0 +1,52 @@
import type { ReactNode } from 'react'
import { classNames } from '../lib/class-names'
import { Surface } from '../primitives/surface'
export type StatePanelTone = 'neutral' | 'info' | 'warning' | 'danger'
export interface StatePanelProps {
readonly title: string
readonly description: ReactNode
readonly tone?: StatePanelTone
readonly icon?: ReactNode
readonly actions?: ReactNode
readonly className?: string
readonly live?: boolean
}
export function StatePanel({
title,
description,
tone = 'neutral',
icon,
actions,
className,
live = false,
}: StatePanelProps) {
return (
<Surface
as="section"
className={classNames(
'drb-state-panel',
`drb-state-panel--${tone}`,
className,
)}
role={tone === 'danger' ? 'alert' : live ? 'status' : undefined}
aria-live={live && tone !== 'danger' ? 'polite' : undefined}
>
{icon ? (
<div className="drb-state-panel__icon" aria-hidden="true">
{icon}
</div>
) : null}
<div className="drb-state-panel__body">
<h2 className="drb-state-panel__title">{title}</h2>
<div className="drb-state-panel__description">{description}</div>
{actions ? (
<div className="drb-state-panel__actions">{actions}</div>
) : null}
</div>
</Surface>
)
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"declaration": true,
"jsx": "react-jsx",
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}