@lovett/ui 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2659 -0
- package/dist/index.js +14451 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +636 -0
- package/dist/tokens.css +700 -0
- package/package.json +44 -17
- package/src/__tests__/button.test.tsx +137 -0
- package/src/__tests__/card.test.tsx +103 -0
- package/src/__tests__/dead-render.test.tsx +117 -0
- package/src/__tests__/input.test.tsx +134 -0
- package/src/__tests__/modal.test.tsx +154 -0
- package/src/__tests__/page-shell.test.tsx +128 -0
- package/src/__tests__/setup.ts +43 -0
- package/src/__tests__/token-shape.test.ts +193 -0
- package/src/allocation-sparkbar.tsx +90 -0
- package/src/card.tsx +1 -1
- package/src/collapsible-card.tsx +85 -0
- package/src/data-grid/table-body.tsx +8 -1
- package/src/dropdown-menu.tsx +1 -1
- package/src/floating-status-bar.tsx +112 -0
- package/src/folder-tree-picker.tsx +5 -6
- package/src/frame-stack.tsx +27 -10
- package/src/hero-form-card.tsx +2 -2
- package/src/icons/brand.tsx +187 -0
- package/src/index.ts +43 -0
- package/src/lib/clipboard.ts +14 -0
- package/src/lib/color.ts +111 -0
- package/src/meta-cell.tsx +52 -0
- package/src/meta-previews/MetaFeedCarousel.tsx +1 -1
- package/src/meta-previews/MetaFeedPreview.tsx +1 -1
- package/src/microsoft-logo.tsx +33 -0
- package/src/modal.tsx +77 -6
- package/src/pill-button.tsx +23 -5
- package/src/profile-section.tsx +40 -9
- package/src/sortable-table.tsx +5 -1
- package/src/styles.css +74 -0
- package/src/tabs.tsx +4 -0
- package/src/tag-chip-input.tsx +1 -1
- package/src/theme-v2.css +466 -0
- package/src/token-badge.tsx +92 -0
- package/src/tokens.css +181 -60
- package/src/v2/README.md +208 -0
- package/src/v2/__demo__/showcase.tsx +1045 -0
- package/src/v2/action.tsx +91 -0
- package/src/v2/callout.tsx +76 -0
- package/src/v2/document-section.tsx +82 -0
- package/src/v2/document-shell.tsx +0 -0
- package/src/v2/field-row.tsx +113 -0
- package/src/v2/icons.tsx +165 -0
- package/src/v2/index.ts +147 -0
- package/src/v2/layout.tsx +293 -0
- package/src/v2/progress-track.tsx +89 -0
- package/src/v2/stat-tile.tsx +129 -0
- package/src/v2/states.tsx +271 -0
- package/src/v2/status-pill.tsx +74 -0
- package/src/v2/theme.css +1861 -0
- package/src/v2/timeline.tsx +81 -0
- package/src/v2/tokens.ts +228 -0
- package/src/value-chip.tsx +76 -0
package/src/lib/color.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour maths for brand-palette surfaces — hex to rgb/hsl, WCAG contrast.
|
|
3
|
+
*
|
|
4
|
+
* Promoted from `apps/extension/src/sidepanel/color-utils.ts` when the
|
|
5
|
+
* workspace's Brand Profile grew the same colour cards the extension's
|
|
6
|
+
* Colors tab already had. Two consumers, one implementation — the same
|
|
7
|
+
* rule the platform-chrome tokens were consolidated under.
|
|
8
|
+
*
|
|
9
|
+
* Pure functions, no deps, no DOM.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type RgbTuple = [number, number, number]
|
|
13
|
+
|
|
14
|
+
/** Parse `#abc` or `#aabbcc` (with or without the hash) into an rgb tuple. */
|
|
15
|
+
export function hexToRgbTuple(hex: string): RgbTuple {
|
|
16
|
+
const h = hex.replace('#', '')
|
|
17
|
+
const full =
|
|
18
|
+
h.length === 3
|
|
19
|
+
? h
|
|
20
|
+
.split('')
|
|
21
|
+
.map((c) => c + c)
|
|
22
|
+
.join('')
|
|
23
|
+
: h
|
|
24
|
+
const r = parseInt(full.slice(0, 2), 16) || 0
|
|
25
|
+
const g = parseInt(full.slice(2, 4), 16) || 0
|
|
26
|
+
const b = parseInt(full.slice(4, 6), 16) || 0
|
|
27
|
+
return [r, g, b]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `"24, 119, 242"` — display form, the inside of an `rgb()`. */
|
|
31
|
+
export function rgbString(hex: string): string {
|
|
32
|
+
const [r, g, b] = hexToRgbTuple(hex)
|
|
33
|
+
return `${r}, ${g}, ${b}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `"rgb(24, 119, 242)"` — a complete CSS value, for copying.
|
|
38
|
+
*
|
|
39
|
+
* Lives here rather than being assembled at the call site because the
|
|
40
|
+
* token-boundary lint reads `rgb(...)` in app code as a raw colour
|
|
41
|
+
* literal — correctly, since it cannot tell a clipboard string from a
|
|
42
|
+
* style. packages/ui is the exempt substrate, so this is its job.
|
|
43
|
+
*/
|
|
44
|
+
export function rgbCss(hex: string): string {
|
|
45
|
+
return `rgb(${rgbString(hex)})`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `"217° 89% 52%"` — display form, not a CSS value. */
|
|
49
|
+
export function hslString(hex: string): string {
|
|
50
|
+
const [r, g, b] = hexToRgbTuple(hex).map((v) => v / 255) as RgbTuple
|
|
51
|
+
const max = Math.max(r, g, b)
|
|
52
|
+
const min = Math.min(r, g, b)
|
|
53
|
+
const l = (max + min) / 2
|
|
54
|
+
let h = 0
|
|
55
|
+
let s = 0
|
|
56
|
+
if (max !== min) {
|
|
57
|
+
const d = max - min
|
|
58
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
|
59
|
+
if (max === r) h = (g - b) / d + (g < b ? 6 : 0)
|
|
60
|
+
else if (max === g) h = (b - r) / d + 2
|
|
61
|
+
else h = (r - g) / d + 4
|
|
62
|
+
h /= 6
|
|
63
|
+
}
|
|
64
|
+
return `${Math.round(h * 360)}° ${Math.round(s * 100)}% ${Math.round(l * 100)}%`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** WCAG 2.1 relative luminance (sRGB). */
|
|
68
|
+
export function relativeLuminance(hex: string): number {
|
|
69
|
+
const lin = hexToRgbTuple(hex).map((v) => {
|
|
70
|
+
const c = v / 255
|
|
71
|
+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
|
72
|
+
}) as RgbTuple
|
|
73
|
+
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Contrast ratio between two relative luminances. */
|
|
77
|
+
export function contrastRatio(a: number, b: number): number {
|
|
78
|
+
const [hi, lo] = a > b ? [a, b] : [b, a]
|
|
79
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether black or white text reads better ON this colour, and at what
|
|
84
|
+
* ratio. Used to pick a legible foreground for a swatch banner.
|
|
85
|
+
*/
|
|
86
|
+
export function bestTextOn(hex: string): { onLight: boolean; ratio: number } {
|
|
87
|
+
const l = relativeLuminance(hex)
|
|
88
|
+
const vsWhite = contrastRatio(l, 1)
|
|
89
|
+
const vsBlack = contrastRatio(l, 0)
|
|
90
|
+
return { onLight: vsBlack >= vsWhite, ratio: Math.max(vsWhite, vsBlack) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface WcagBadge {
|
|
94
|
+
label: string
|
|
95
|
+
pass: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The three standard thresholds, scored against the BEST of black-or-white
|
|
100
|
+
* text on this swatch. It answers "can any text sit on this colour", which
|
|
101
|
+
* is the useful question for a brand palette — not "is this colour itself
|
|
102
|
+
* readable", which depends on a background the palette doesn't know.
|
|
103
|
+
*/
|
|
104
|
+
export function wcagBadges(hex: string): WcagBadge[] {
|
|
105
|
+
const best = bestTextOn(hex).ratio
|
|
106
|
+
return [
|
|
107
|
+
{ label: 'AA Large', pass: best >= 3 },
|
|
108
|
+
{ label: 'AA', pass: best >= 4.5 },
|
|
109
|
+
{ label: 'AAA', pass: best >= 7 },
|
|
110
|
+
]
|
|
111
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MetaCell — a labelled fact in a recessed well.
|
|
3
|
+
*
|
|
4
|
+
* The second v2 atom: a micro uppercase label over a value, on
|
|
5
|
+
* `--surface-inset`. Every "Category / Elements found / Updated / Weight"
|
|
6
|
+
* pair in the product is this shape, and it had been hand-rolled per
|
|
7
|
+
* lens with five different label sizes and four tracking values.
|
|
8
|
+
*
|
|
9
|
+
* Numbers get `tabular-nums` unconditionally — a MetaCell value is by
|
|
10
|
+
* definition something you compare or watch update.
|
|
11
|
+
*/
|
|
12
|
+
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
|
|
13
|
+
import { cn } from './lib/utils'
|
|
14
|
+
|
|
15
|
+
export interface MetaCellProps extends HTMLAttributes<HTMLDivElement> {
|
|
16
|
+
label: ReactNode
|
|
17
|
+
children: ReactNode
|
|
18
|
+
/** Optional leading glyph in the label row. */
|
|
19
|
+
icon?: ReactNode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const MetaCell = forwardRef<HTMLDivElement, MetaCellProps>(
|
|
23
|
+
function MetaCell({ label, children, icon, className, style, ...rest }, ref) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
ref={ref}
|
|
27
|
+
className={cn('flex flex-col gap-0.5 px-2.5 py-2 min-w-0', className)}
|
|
28
|
+
style={{
|
|
29
|
+
background: 'rgb(var(--surface-inset))',
|
|
30
|
+
border: '1px solid rgb(var(--border))',
|
|
31
|
+
borderRadius: 'var(--radius-sm)',
|
|
32
|
+
...style,
|
|
33
|
+
}}
|
|
34
|
+
{...rest}
|
|
35
|
+
>
|
|
36
|
+
<span
|
|
37
|
+
className="inline-flex items-center gap-1 text-[9px] font-semibold uppercase tracking-[0.06em]"
|
|
38
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
39
|
+
>
|
|
40
|
+
{icon}
|
|
41
|
+
{label}
|
|
42
|
+
</span>
|
|
43
|
+
<span
|
|
44
|
+
className="text-[12px] font-semibold tabular-nums truncate"
|
|
45
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
46
|
+
>
|
|
47
|
+
{children}
|
|
48
|
+
</span>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
},
|
|
52
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MicrosoftLogo — the Microsoft four-square brand mark, for "Sign in with
|
|
3
|
+
* Microsoft" SSO buttons (ADR-124 D8). Lucide has no Microsoft glyph, so this
|
|
4
|
+
* is a bespoke SVG primitive — which `@lovett/ui` is the one allowed home for
|
|
5
|
+
* (CLAUDE.md §2/§6). The four square colours are Microsoft's required brand
|
|
6
|
+
* colours (per their identity guidelines); they are intrinsic to the mark, not
|
|
7
|
+
* theme tokens, exactly like BrandLogoTile's imagery — so they live here as
|
|
8
|
+
* literals rather than `tokens.css` variables.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface MicrosoftLogoProps {
|
|
12
|
+
/** Square size in px. Default 18 (matches a control-height button glyph). */
|
|
13
|
+
size?: number
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function MicrosoftLogo({ size = 18, className }: MicrosoftLogoProps) {
|
|
18
|
+
return (
|
|
19
|
+
<svg
|
|
20
|
+
width={size}
|
|
21
|
+
height={size}
|
|
22
|
+
viewBox="0 0 21 21"
|
|
23
|
+
className={className}
|
|
24
|
+
aria-hidden="true"
|
|
25
|
+
focusable="false"
|
|
26
|
+
>
|
|
27
|
+
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
|
|
28
|
+
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
|
|
29
|
+
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
|
|
30
|
+
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
|
|
31
|
+
</svg>
|
|
32
|
+
)
|
|
33
|
+
}
|
package/src/modal.tsx
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
isValidElement,
|
|
4
|
+
useEffect,
|
|
5
|
+
type HTMLAttributes,
|
|
6
|
+
type ReactElement,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react'
|
|
2
9
|
import { X } from 'lucide-react'
|
|
3
10
|
import { cn } from './lib/utils'
|
|
4
11
|
|
|
@@ -74,28 +81,92 @@ function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
|
|
74
81
|
{/* Header — title + close. Sits on the outer card surface,
|
|
75
82
|
outside any tray. Shrink-0 so it never collapses when the
|
|
76
83
|
body grows. */}
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
{/* Header. Geometry from the B/C proposal at /design-system/modals:
|
|
85
|
+
12px/16px padding, a 24px close button and centre alignment.
|
|
86
|
+
|
|
87
|
+
It was px-6 pt-6 pb-4 with a 32px close button, which measured
|
|
88
|
+
60px tall for a 23px title — and because the button was taller
|
|
89
|
+
than the text, the BUTTON set the row height and the title
|
|
90
|
+
floated in slack it never asked for. */}
|
|
91
|
+
<div className="flex items-center justify-between gap-3 shrink-0 px-4 py-3">
|
|
92
|
+
<h2 className="text-[15px] font-bold tracking-[-0.01em] text-[rgb(var(--foreground))]">
|
|
79
93
|
{title}
|
|
80
94
|
</h2>
|
|
81
95
|
<button
|
|
82
96
|
onClick={onClose}
|
|
83
|
-
className="
|
|
97
|
+
className="inline-flex items-center justify-center w-6 h-6 shrink-0 rounded-[var(--radius-sm)] text-[rgb(var(--text-tertiary))] hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))] transition-colors"
|
|
84
98
|
aria-label="Close"
|
|
85
99
|
>
|
|
86
|
-
<X className="h-
|
|
100
|
+
<X className="h-[15px] w-[15px]" />
|
|
87
101
|
</button>
|
|
88
102
|
</div>
|
|
89
|
-
{children}
|
|
103
|
+
{renderBody(children)}
|
|
90
104
|
</div>
|
|
91
105
|
</div>
|
|
92
106
|
)
|
|
93
107
|
}
|
|
94
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Partition Tray + Footer into ONE tray box, footer last, divider between.
|
|
111
|
+
*
|
|
112
|
+
* Why this exists. Consumers write Tray and Footer as siblings, and until
|
|
113
|
+
* now they rendered that way: the tray at --tray-inset (4px) and the
|
|
114
|
+
* footer on the outer card at px-6 (24px). Three things went wrong at
|
|
115
|
+
* once — the buttons sat 20px inboard of the tray edge they were meant
|
|
116
|
+
* to align with, `.ds-card-tray:not(:last-child)` zeroed the tray's
|
|
117
|
+
* bottom margin so its lower corners were cut flush, and the actions
|
|
118
|
+
* ended up floating in the frame gap instead of on the decision surface.
|
|
119
|
+
*
|
|
120
|
+
* The tray is the decision surface, so the actions belong inside it.
|
|
121
|
+
* Done here rather than by editing 26 consumers, and it keeps the
|
|
122
|
+
* sibling API — the same auto-wrap trick <Card> uses for Head/Body/Foot.
|
|
123
|
+
*/
|
|
124
|
+
function renderBody(children: ReactNode): ReactNode {
|
|
125
|
+
const kids = Children.toArray(children)
|
|
126
|
+
const tray = kids.find(
|
|
127
|
+
(c): c is ReactElement<HTMLAttributes<HTMLDivElement>> =>
|
|
128
|
+
isValidElement(c) && c.type === Tray,
|
|
129
|
+
)
|
|
130
|
+
const footer = kids.find(
|
|
131
|
+
(c): c is ReactElement<HTMLAttributes<HTMLDivElement>> =>
|
|
132
|
+
isValidElement(c) && c.type === Footer,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
// Flat children, or a tray with no actions — nothing to reconcile.
|
|
136
|
+
if (!tray || !footer) return children
|
|
137
|
+
|
|
138
|
+
const rest = kids.filter((c) => c !== tray && c !== footer)
|
|
139
|
+
const trayProps = tray.props
|
|
140
|
+
const footerProps = footer.props
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<>
|
|
144
|
+
{rest}
|
|
145
|
+
<div className="ds-card-tray flex flex-col flex-1 min-h-0">
|
|
146
|
+
<div
|
|
147
|
+
{...trayProps}
|
|
148
|
+
className={cn('flex-1 min-h-0 overflow-y-auto p-5', trayProps.className)}
|
|
149
|
+
/>
|
|
150
|
+
<div
|
|
151
|
+
{...footerProps}
|
|
152
|
+
className={cn(
|
|
153
|
+
'flex items-center justify-end gap-2 shrink-0 px-5 py-3.5 border-t',
|
|
154
|
+
footerProps.className,
|
|
155
|
+
)}
|
|
156
|
+
style={{ borderColor: 'rgb(var(--border))', ...footerProps.style }}
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
</>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
95
163
|
/**
|
|
96
164
|
* Modal.Tray — the recessed body panel. Sits inside the modal's outer
|
|
97
165
|
* card with --tray-inset margin and its own bg. Scrolls if content
|
|
98
166
|
* exceeds the modal's max-height.
|
|
167
|
+
*
|
|
168
|
+
* When a <Modal.Footer> is also present, `renderBody` above merges the
|
|
169
|
+
* two into a single tray and this standalone styling is not used.
|
|
99
170
|
*/
|
|
100
171
|
function Tray({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
101
172
|
return (
|
package/src/pill-button.tsx
CHANGED
|
@@ -17,22 +17,40 @@
|
|
|
17
17
|
import type { ReactNode } from 'react'
|
|
18
18
|
import type { BadgeTone } from './badge'
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Active (filled) tone map.
|
|
22
|
+
*
|
|
23
|
+
* This was an inverted copy of <Badge>'s TONE_STYLE: Badge pairs a tinted
|
|
24
|
+
* `*-bg` fill with solid `*` text, and success/warning/info were copied
|
|
25
|
+
* across with bg and color still in Badge's order — painting the label in
|
|
26
|
+
* a 12%-alpha version of its own background. Measured contrast was ~1:1,
|
|
27
|
+
* i.e. the label was not low-contrast, it was invisible.
|
|
28
|
+
*
|
|
29
|
+
* A filled pill needs a foreground that reads on a solid fill, which is
|
|
30
|
+
* exactly what --surface-on-accent is for (ADR-049).
|
|
31
|
+
*
|
|
32
|
+
* KNOWN GAP: the solid semantic fills are themselves too light for white
|
|
33
|
+
* at AA — warning is the worst (2.15:1 dark / 3.19:1 light). That is the
|
|
34
|
+
* same defect Badge has in 6 of 12 tone x theme combos and it belongs to
|
|
35
|
+
* the semantic-token retune, not here; fixing it per-component would bake
|
|
36
|
+
* in a value the retune has to undo.
|
|
37
|
+
*/
|
|
20
38
|
const ACTIVE_STYLE: Record<BadgeTone, { bg: string; color: string }> = {
|
|
21
39
|
neutral: {
|
|
22
40
|
bg: 'rgb(var(--accent))',
|
|
23
|
-
color: 'rgb(var(--accent
|
|
41
|
+
color: 'rgb(var(--surface-on-accent))',
|
|
24
42
|
},
|
|
25
43
|
accent: {
|
|
26
44
|
bg: 'rgb(var(--accent))',
|
|
27
|
-
color: 'rgb(var(--accent
|
|
45
|
+
color: 'rgb(var(--surface-on-accent))',
|
|
28
46
|
},
|
|
29
47
|
success: {
|
|
30
48
|
bg: 'rgb(var(--success))',
|
|
31
|
-
color: 'rgb(var(--
|
|
49
|
+
color: 'rgb(var(--surface-on-accent))',
|
|
32
50
|
},
|
|
33
51
|
warning: {
|
|
34
52
|
bg: 'rgb(var(--warning))',
|
|
35
|
-
color: 'rgb(var(--
|
|
53
|
+
color: 'rgb(var(--surface-on-accent))',
|
|
36
54
|
},
|
|
37
55
|
destructive: {
|
|
38
56
|
bg: 'rgb(var(--destructive))',
|
|
@@ -40,7 +58,7 @@ const ACTIVE_STYLE: Record<BadgeTone, { bg: string; color: string }> = {
|
|
|
40
58
|
},
|
|
41
59
|
info: {
|
|
42
60
|
bg: 'rgb(var(--info))',
|
|
43
|
-
color: 'rgb(var(--
|
|
61
|
+
color: 'rgb(var(--surface-on-accent))',
|
|
44
62
|
},
|
|
45
63
|
}
|
|
46
64
|
|
package/src/profile-section.tsx
CHANGED
|
@@ -49,10 +49,14 @@ function SectionHead({ icon, title, subtitle, actions }: SectionHeadProps) {
|
|
|
49
49
|
? cloneElement(
|
|
50
50
|
icon as ReactElement<{ className?: string; size?: number | string }>,
|
|
51
51
|
{
|
|
52
|
+
// Sizing rides on `size`, which Lucide honours. A previous
|
|
53
|
+
// `h-[${HEAD_ICON_SIZE}px]` here never did anything: Tailwind
|
|
54
|
+
// scans source statically, so an interpolated class is never
|
|
55
|
+
// generated. Keep the literal in sync with HEAD_ICON_SIZE.
|
|
52
56
|
size: HEAD_ICON_SIZE,
|
|
53
57
|
className: cn(
|
|
54
58
|
(icon.props as { className?: string }).className,
|
|
55
|
-
|
|
59
|
+
'h-[17px] w-[17px]',
|
|
56
60
|
),
|
|
57
61
|
},
|
|
58
62
|
)
|
|
@@ -267,6 +271,20 @@ interface BaseProps {
|
|
|
267
271
|
foot?: FootProps
|
|
268
272
|
/** Extra padding tweak. */
|
|
269
273
|
className?: string
|
|
274
|
+
/**
|
|
275
|
+
* Render as a SINGLE flat surface — header, divider, body — instead of
|
|
276
|
+
* the default frame-plus-inner-tray.
|
|
277
|
+
*
|
|
278
|
+
* Use this when the section is already inside a <FrameStack>. A framed
|
|
279
|
+
* section nested in a frame gives you three surface tiers (group frame →
|
|
280
|
+
* section frame → tray), which reads as boxes inside boxes. Flat is the
|
|
281
|
+
* shape <SectionCard> uses in the Social Spec builder, and it is what
|
|
282
|
+
* makes a group of sections read as one contained unit rather than as a
|
|
283
|
+
* stack of independent cards.
|
|
284
|
+
*
|
|
285
|
+
* Default (false) is unchanged, so every existing consumer is unaffected.
|
|
286
|
+
*/
|
|
287
|
+
flat?: boolean
|
|
270
288
|
}
|
|
271
289
|
|
|
272
290
|
interface StaticSectionProps extends BaseProps {
|
|
@@ -346,11 +364,13 @@ function ProfileSection(props: ProfileSectionProps) {
|
|
|
346
364
|
? (props.children as (e: boolean) => ReactNode)(isEditing)
|
|
347
365
|
: (props.children as ReactNode)
|
|
348
366
|
|
|
367
|
+
const flat = props.flat ?? false
|
|
368
|
+
|
|
349
369
|
return (
|
|
350
370
|
<section
|
|
351
371
|
id={id}
|
|
352
372
|
data-section-id={id}
|
|
353
|
-
data-framed=
|
|
373
|
+
data-framed={flat ? undefined : 'true'}
|
|
354
374
|
className={cn('ds-card-surface flex flex-col', className)}
|
|
355
375
|
style={{ scrollMarginTop: 'var(--section-scroll-offset)' }}
|
|
356
376
|
>
|
|
@@ -360,13 +380,24 @@ function ProfileSection(props: ProfileSectionProps) {
|
|
|
360
380
|
subtitle={subtitle}
|
|
361
381
|
actions={headActions}
|
|
362
382
|
/>
|
|
363
|
-
{
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
383
|
+
{flat ? (
|
|
384
|
+
/* One surface. The divider does the separating that the tray's
|
|
385
|
+
fill does in the default shape. */
|
|
386
|
+
<div
|
|
387
|
+
className="px-4 pb-4 pt-4 border-t"
|
|
388
|
+
style={{ flex: '1 1 auto', borderColor: 'rgb(var(--border))' }}
|
|
389
|
+
>
|
|
390
|
+
{children}
|
|
391
|
+
</div>
|
|
392
|
+
) : (
|
|
393
|
+
/* flex:1 lets the tray fill remaining height when the section is
|
|
394
|
+
stretched (e.g. side-by-side in an equal-height grid), so trays
|
|
395
|
+
bottom-align instead of leaving a gap under a shorter-header card.
|
|
396
|
+
No effect on un-stretched (vertically-stacked) sections. */
|
|
397
|
+
<div className="ds-card-tray p-5" style={{ flex: '1 1 auto' }}>
|
|
398
|
+
{children}
|
|
399
|
+
</div>
|
|
400
|
+
)}
|
|
370
401
|
{foot && <Foot {...foot} />}
|
|
371
402
|
</section>
|
|
372
403
|
)
|
package/src/sortable-table.tsx
CHANGED
|
@@ -172,8 +172,12 @@ export function SortableTable<T>({
|
|
|
172
172
|
}
|
|
173
173
|
style={{
|
|
174
174
|
background:
|
|
175
|
+
/* --surface-overlay-soft already carries an alpha; nesting a
|
|
176
|
+
second one produced `rgb(0 0 0 / 0.03 / 0.5)` — invalid, so
|
|
177
|
+
the banding never painted. --muted is the opaque zebra fill
|
|
178
|
+
the data-display recipe (CLAUDE.md §2) calls for. */
|
|
175
179
|
zebra && idx % 2 === 1
|
|
176
|
-
? 'rgb(var(--
|
|
180
|
+
? 'rgb(var(--muted))'
|
|
177
181
|
: 'transparent',
|
|
178
182
|
}}
|
|
179
183
|
>
|
package/src/styles.css
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
* .has-leading/trailing, .is-error/disabled,
|
|
26
26
|
* .size-sm/lg — Input, Textarea
|
|
27
27
|
* .scrollbar-hide — ChipNav
|
|
28
|
+
* .ds-enter-pop, .ds-enter-rise — DropdownMenu,
|
|
29
|
+
* FloatingStatusBar
|
|
28
30
|
* prefers-reduced-motion block — accessibility
|
|
29
31
|
*
|
|
30
32
|
* Out of scope for Phase 1: .empty, .skel, .field*, .pg-header, .search-bar,
|
|
@@ -33,6 +35,24 @@
|
|
|
33
35
|
* consumers (Field, EmptyState, PageHeader, etc.) arrive in later phases.
|
|
34
36
|
*/
|
|
35
37
|
|
|
38
|
+
/* ---- PANE MASK ---- */
|
|
39
|
+
/**
|
|
40
|
+
* Opaque fill matching the MainLayout content pane, for a sticky element that
|
|
41
|
+
* must hide content scrolling beneath it.
|
|
42
|
+
*
|
|
43
|
+
* Exists because the mask has to equal the pane EXACTLY or content shows
|
|
44
|
+
* through / bands, and the only token that does is `--main-content-bg` —
|
|
45
|
+
* internal, so lens code cannot name it (CLAUDE.md §5). Same shape as
|
|
46
|
+
* `.ds-card-tray` owning `--tray-bg`: the class lives here, consumers use the
|
|
47
|
+
* class. This is NOT a raised surface — do not reach for `--surface-raised`
|
|
48
|
+
* (ADR-063); a sticky mask must match the pane, not sit above it.
|
|
49
|
+
*
|
|
50
|
+
* Consumers: sem-spec `output/index.tsx`, `share-view/ad-preview-section.tsx`.
|
|
51
|
+
*/
|
|
52
|
+
.ds-pane-mask {
|
|
53
|
+
background: rgb(var(--main-content-bg));
|
|
54
|
+
}
|
|
55
|
+
|
|
36
56
|
/* ---- CARD SURFACE ---- */
|
|
37
57
|
.ds-card-surface {
|
|
38
58
|
background: rgb(var(--bg-card));
|
|
@@ -57,6 +77,11 @@
|
|
|
57
77
|
|
|
58
78
|
/* ---- CARD TRAY ---- */
|
|
59
79
|
.ds-card-tray {
|
|
80
|
+
/* 4px, not 10. Concentric radius is outer = inner + padding, so a
|
|
81
|
+
16px card around a 12px tray wants a 4px inset — at 10 the maths
|
|
82
|
+
was 22 and the corners fought each other. It is also what every
|
|
83
|
+
kit in the corpus measures (AI Agent: pad 4, r16 outer / r12
|
|
84
|
+
inner), and it is what reads as thinner and sleeker. */
|
|
60
85
|
margin: var(--tray-inset);
|
|
61
86
|
background: rgb(var(--tray-bg));
|
|
62
87
|
/* Owner ask: visible 1px border on the inner tray so the tray surface
|
|
@@ -534,8 +559,57 @@
|
|
|
534
559
|
transform: scaleY(2);
|
|
535
560
|
}
|
|
536
561
|
|
|
562
|
+
/* ---- ENTRANCE ANIMATIONS ----
|
|
563
|
+
*
|
|
564
|
+
* These exist because `animate-in` / `fade-in-0` / `zoom-in-95` /
|
|
565
|
+
* `slide-in-from-bottom-2` are NOT Tailwind core utilities — they ship in
|
|
566
|
+
* `tailwindcss-animate` / `tw-animate-css`, neither of which is a dependency
|
|
567
|
+
* of this repo. Both of the kit's entrance animations were therefore
|
|
568
|
+
* referencing classes that were never generated and had never run, while
|
|
569
|
+
* typecheck, lint and build stayed green.
|
|
570
|
+
*
|
|
571
|
+
* Adding the dependency would need an ADR (CLAUDE.md §1); two keyframes do
|
|
572
|
+
* not. Durations come from the motion tokens rather than being re-specified
|
|
573
|
+
* per call site.
|
|
574
|
+
*/
|
|
575
|
+
@keyframes ds-enter-pop {
|
|
576
|
+
from {
|
|
577
|
+
opacity: 0;
|
|
578
|
+
transform: scale(0.95);
|
|
579
|
+
}
|
|
580
|
+
to {
|
|
581
|
+
opacity: 1;
|
|
582
|
+
transform: scale(1);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
@keyframes ds-enter-rise {
|
|
586
|
+
from {
|
|
587
|
+
opacity: 0;
|
|
588
|
+
transform: translateY(0.5rem);
|
|
589
|
+
}
|
|
590
|
+
to {
|
|
591
|
+
opacity: 1;
|
|
592
|
+
transform: translateY(0);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/* Popovers, menus, dropdowns — anchored to a trigger, so they scale open. */
|
|
596
|
+
.ds-enter-pop {
|
|
597
|
+
animation: ds-enter-pop var(--dur-fast) var(--ease-out);
|
|
598
|
+
}
|
|
599
|
+
/* Bars and toasts entering from a screen edge. Animates `transform`, which
|
|
600
|
+
* composes with (rather than overwrites) a `translate`-based centering
|
|
601
|
+
* utility such as `-translate-x-1/2`. */
|
|
602
|
+
.ds-enter-rise {
|
|
603
|
+
animation: ds-enter-rise var(--dur-base) var(--ease-out);
|
|
604
|
+
}
|
|
605
|
+
|
|
537
606
|
/* ---- REDUCED MOTION ---- */
|
|
538
607
|
@media (prefers-reduced-motion: reduce) {
|
|
608
|
+
/* 0.01ms rather than `none` so animationend still fires for any listener. */
|
|
609
|
+
.ds-enter-pop,
|
|
610
|
+
.ds-enter-rise {
|
|
611
|
+
animation-duration: 0.01ms !important;
|
|
612
|
+
}
|
|
539
613
|
.btn,
|
|
540
614
|
.btn-primary,
|
|
541
615
|
.input-shell {
|
package/src/tabs.tsx
CHANGED
|
@@ -23,6 +23,9 @@ export interface Tab {
|
|
|
23
23
|
/** Optional count badge rendered after the label. */
|
|
24
24
|
count?: number
|
|
25
25
|
disabled?: boolean
|
|
26
|
+
/** Accessible name for ICON-ONLY tabs (label rendered empty) — e.g. the
|
|
27
|
+
* responsive platform switcher's compact tier (ADR-135). */
|
|
28
|
+
ariaLabel?: string
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export interface TabsProps {
|
|
@@ -106,6 +109,7 @@ export function Tabs({
|
|
|
106
109
|
role="tab"
|
|
107
110
|
type="button"
|
|
108
111
|
aria-selected={isActive}
|
|
112
|
+
aria-label={tab.ariaLabel}
|
|
109
113
|
disabled={tab.disabled}
|
|
110
114
|
onClick={() => onTabChange(tab.id)}
|
|
111
115
|
className={`${className} ${tab.disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}`}
|
package/src/tag-chip-input.tsx
CHANGED
|
@@ -248,7 +248,7 @@ export function TagChipInput({
|
|
|
248
248
|
'flex flex-wrap items-center gap-1.5 px-2 py-1.5 min-h-10 cursor-text',
|
|
249
249
|
'border transition-colors',
|
|
250
250
|
error
|
|
251
|
-
? 'border-[rgb(var(--
|
|
251
|
+
? 'border-[rgb(var(--destructive))] focus-within:border-[rgb(var(--destructive))]'
|
|
252
252
|
: 'border-[rgb(var(--border))] focus-within:border-[rgb(var(--accent))]',
|
|
253
253
|
isDisabled && 'opacity-60 cursor-not-allowed',
|
|
254
254
|
className,
|