@lovett/ui 0.0.5 → 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.
Files changed (56) hide show
  1. package/dist/index.d.ts +279 -115
  2. package/dist/index.js +479 -66
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles.css +74 -0
  5. package/dist/tokens.css +181 -60
  6. package/package.json +21 -4
  7. package/src/__tests__/button.test.tsx +137 -0
  8. package/src/__tests__/card.test.tsx +103 -0
  9. package/src/__tests__/dead-render.test.tsx +117 -0
  10. package/src/__tests__/input.test.tsx +134 -0
  11. package/src/__tests__/modal.test.tsx +154 -0
  12. package/src/__tests__/page-shell.test.tsx +128 -0
  13. package/src/__tests__/setup.ts +43 -0
  14. package/src/__tests__/token-shape.test.ts +193 -0
  15. package/src/card.tsx +1 -1
  16. package/src/collapsible-card.tsx +85 -0
  17. package/src/data-grid/table-body.tsx +8 -1
  18. package/src/dropdown-menu.tsx +1 -1
  19. package/src/floating-status-bar.tsx +1 -1
  20. package/src/folder-tree-picker.tsx +5 -6
  21. package/src/frame-stack.tsx +27 -10
  22. package/src/hero-form-card.tsx +2 -2
  23. package/src/icons/brand.tsx +187 -0
  24. package/src/index.ts +32 -0
  25. package/src/lib/clipboard.ts +14 -0
  26. package/src/lib/color.ts +111 -0
  27. package/src/meta-cell.tsx +52 -0
  28. package/src/meta-previews/MetaFeedCarousel.tsx +1 -1
  29. package/src/meta-previews/MetaFeedPreview.tsx +1 -1
  30. package/src/modal.tsx +77 -6
  31. package/src/pill-button.tsx +23 -5
  32. package/src/profile-section.tsx +40 -9
  33. package/src/sortable-table.tsx +5 -1
  34. package/src/styles.css +74 -0
  35. package/src/tabs.tsx +4 -0
  36. package/src/tag-chip-input.tsx +1 -1
  37. package/src/theme-v2.css +466 -0
  38. package/src/tokens.css +181 -60
  39. package/src/v2/README.md +208 -0
  40. package/src/v2/__demo__/showcase.tsx +1045 -0
  41. package/src/v2/action.tsx +91 -0
  42. package/src/v2/callout.tsx +76 -0
  43. package/src/v2/document-section.tsx +82 -0
  44. package/src/v2/document-shell.tsx +0 -0
  45. package/src/v2/field-row.tsx +113 -0
  46. package/src/v2/icons.tsx +165 -0
  47. package/src/v2/index.ts +147 -0
  48. package/src/v2/layout.tsx +293 -0
  49. package/src/v2/progress-track.tsx +89 -0
  50. package/src/v2/stat-tile.tsx +129 -0
  51. package/src/v2/states.tsx +271 -0
  52. package/src/v2/status-pill.tsx +74 -0
  53. package/src/v2/theme.css +1861 -0
  54. package/src/v2/timeline.tsx +81 -0
  55. package/src/v2/tokens.ts +228 -0
  56. package/src/value-chip.tsx +76 -0
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // `@lovett/ui/styles.css` — component CSS the primitives render through
10
10
 
11
11
  export { default as Card, IconTile, TagRow } from './card'
12
+ export { CollapsibleCard, type CollapsibleCardProps } from './collapsible-card'
12
13
  export { Button, type ButtonProps, type ButtonVariant, type ButtonSize, type ButtonShape } from './button'
13
14
  export { ButtonGroup, type ButtonGroupProps } from './button-group'
14
15
  export { Input, type InputProps, type InputSize } from './input'
@@ -24,6 +25,20 @@ export { default as ProfileSection, type ProfileSectionProps } from './profile-s
24
25
  export { default as ChipNav, type ChipNavItem } from './chip-nav'
25
26
  export { default as CompletionRing, type CompletionRingProps } from './completion-ring'
26
27
  export { default as MicrosoftLogo, type MicrosoftLogoProps } from './microsoft-logo'
28
+ export {
29
+ BrandMeta,
30
+ BrandFacebook,
31
+ BrandInstagram,
32
+ BrandMessenger,
33
+ BrandLinkedIn,
34
+ BrandNextdoor,
35
+ BrandPinterest,
36
+ BrandSnapchat,
37
+ BrandTikTok,
38
+ BRAND_ICONS,
39
+ type BrandIconKey,
40
+ type BrandIconProps,
41
+ } from './icons/brand'
27
42
  export { default as Choropleth, type ChoroplethProps } from './choropleth'
28
43
  export {
29
44
  default as PageShell,
@@ -78,6 +93,23 @@ export { ProgressBar, type ProgressBarProps } from './progress-bar'
78
93
  export { StepLoader, type StepLoaderProps, type StepLoaderStep } from './step-loader'
79
94
  export { Badge, type BadgeProps, type BadgeTone } from './badge'
80
95
  export { TokenBadge, type TokenBadgeProps } from './token-badge'
96
+ // v2 language atoms — a literal value in a well, and a labelled fact in a
97
+ // well. Both were hand-rolled per lens before; see each file's header.
98
+ export { ValueChip, type ValueChipProps } from './value-chip'
99
+ export { MetaCell, type MetaCellProps } from './meta-cell'
100
+ export { copyText } from './lib/clipboard'
101
+ export {
102
+ hexToRgbTuple,
103
+ rgbString,
104
+ rgbCss,
105
+ hslString,
106
+ relativeLuminance,
107
+ contrastRatio,
108
+ bestTextOn,
109
+ wcagBadges,
110
+ type RgbTuple,
111
+ type WcagBadge,
112
+ } from './lib/color'
81
113
  export {
82
114
  AllocationSparkbar,
83
115
  type AllocationSparkbarProps,
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Copy-with-confirmation. Promoted from the extension's local helper so
3
+ * the toast wording is identical everywhere a value is copyable.
4
+ */
5
+ import { toast } from '../toast'
6
+
7
+ export async function copyText(value: string, label?: string): Promise<void> {
8
+ try {
9
+ await navigator.clipboard.writeText(value)
10
+ toast.success(label ? `Copied ${label}` : `Copied ${value}`)
11
+ } catch {
12
+ toast.error('Copy failed')
13
+ }
14
+ }
@@ -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
+ )
@@ -25,7 +25,7 @@ const C = {
25
25
  name: 'rgb(8, 8, 8)',
26
26
  sub: 'rgb(101, 103, 107)',
27
27
  body: 'rgb(0, 0, 0)',
28
- seeMore: '#1877F2',
28
+ seeMore: 'var(--color-meta-blue)',
29
29
  imageBg: '#F0F2F5',
30
30
  cardBorder: 'rgba(0, 0, 0, 0.1)',
31
31
  headline: 'rgb(28, 32, 40)',
@@ -28,7 +28,7 @@ const C = {
28
28
  name: 'rgb(8, 8, 8)',
29
29
  sub: 'rgb(101, 103, 107)',
30
30
  body: 'rgb(0, 0, 0)',
31
- seeMore: '#1877F2',
31
+ seeMore: 'var(--color-meta-blue)',
32
32
  imageBg: '#F0F2F5',
33
33
  imageEdge: 'rgba(0, 0, 0, 0.06)',
34
34
  card: '#F7F8FA',
package/src/modal.tsx CHANGED
@@ -1,4 +1,11 @@
1
- import { useEffect, type HTMLAttributes, type ReactNode } from 'react'
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
- <div className="flex items-center justify-between shrink-0 px-6 pt-6 pb-4">
78
- <h2 className="text-xl font-bold text-[rgb(var(--foreground))]">
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="rounded-lg p-1.5 text-[rgb(var(--muted-foreground))] hover:bg-[rgb(var(--muted))] hover:text-[rgb(var(--foreground))] transition-colors"
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-5 w-5" />
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 (
@@ -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-foreground, 255 255 255))',
41
+ color: 'rgb(var(--surface-on-accent))',
24
42
  },
25
43
  accent: {
26
44
  bg: 'rgb(var(--accent))',
27
- color: 'rgb(var(--accent-foreground, 255 255 255))',
45
+ color: 'rgb(var(--surface-on-accent))',
28
46
  },
29
47
  success: {
30
48
  bg: 'rgb(var(--success))',
31
- color: 'rgb(var(--success-bg))',
49
+ color: 'rgb(var(--surface-on-accent))',
32
50
  },
33
51
  warning: {
34
52
  bg: 'rgb(var(--warning))',
35
- color: 'rgb(var(--warning-bg))',
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(--info-bg))',
61
+ color: 'rgb(var(--surface-on-accent))',
44
62
  },
45
63
  }
46
64
 
@@ -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
- `h-[${HEAD_ICON_SIZE}px] w-[${HEAD_ICON_SIZE}px]`,
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="true"
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
- {/* flex:1 lets the tray fill remaining height when the section is
364
- stretched (e.g. side-by-side in an equal-height grid), so trays
365
- bottom-align instead of leaving a gap under a shorter-header card.
366
- No effect on un-stretched (vertically-stacked) sections. */}
367
- <div className="ds-card-tray p-5" style={{ flex: '1 1 auto' }}>
368
- {children}
369
- </div>
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
  )
@@ -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(--surface-overlay-soft) / 0.5)'
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'}`}
@@ -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(--ring-error))]'
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,