@teamblind-chorus/ui 1.1.0 → 2.0.0

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 (148) hide show
  1. package/README.md +3 -3
  2. package/agents/AGENTS.md +6 -6
  3. package/agents/DESIGN.md +245 -244
  4. package/agents/LOVABLE.md +40 -11
  5. package/agents/catalog.md +10 -8
  6. package/agents/components/avatar-rail/avatar-rail.md +2 -4
  7. package/agents/components/avatar-rail/avatar-rail.spec.json +27 -12
  8. package/agents/components/badge/role.md +7 -9
  9. package/agents/components/badge/role.spec.json +6 -6
  10. package/agents/components/badge/update.md +6 -8
  11. package/agents/components/badge/update.spec.json +5 -5
  12. package/agents/components/banner/banner.family.json +3 -1
  13. package/agents/components/banner/banner.md +66 -15
  14. package/agents/components/banner/banner.spec.json +37 -14
  15. package/agents/components/bottom-sheet/bottom-sheet.md +4 -6
  16. package/agents/components/bottom-sheet/bottom-sheet.spec.json +5 -5
  17. package/agents/components/bubble/bubble.md +8 -10
  18. package/agents/components/bubble/bubble.spec.json +11 -11
  19. package/agents/components/button/button.md +1 -1
  20. package/agents/components/button/check.md +9 -11
  21. package/agents/components/button/check.spec.json +25 -8
  22. package/agents/components/button/fab.md +7 -9
  23. package/agents/components/button/fab.spec.json +27 -10
  24. package/agents/components/button/group.spec.json +4 -4
  25. package/agents/components/button/icon.md +21 -23
  26. package/agents/components/button/icon.spec.json +29 -12
  27. package/agents/components/button/standard.md +40 -42
  28. package/agents/components/button/standard.spec.json +37 -20
  29. package/agents/components/button/text.md +21 -23
  30. package/agents/components/button/text.spec.json +30 -13
  31. package/agents/components/button/toggle.md +7 -9
  32. package/agents/components/button/toggle.spec.json +27 -10
  33. package/agents/components/button/toolbar.md +24 -26
  34. package/agents/components/button/toolbar.spec.json +10 -12
  35. package/agents/components/carousel/carousel.md +1 -1
  36. package/agents/components/carousel/post.md +15 -21
  37. package/agents/components/carousel/post.spec.json +17 -17
  38. package/agents/components/carousel/profile.md +9 -45
  39. package/agents/components/carousel/profile.spec.json +17 -17
  40. package/agents/components/chip/chip.md +1 -1
  41. package/agents/components/chip/filter.md +22 -24
  42. package/agents/components/chip/filter.spec.json +34 -11
  43. package/agents/components/chip/tag.md +22 -24
  44. package/agents/components/chip/tag.spec.json +36 -13
  45. package/agents/components/dialog/dialog.md +1 -3
  46. package/agents/components/dialog/dialog.spec.json +3 -3
  47. package/agents/components/directory-list/directory-list.md +1 -3
  48. package/agents/components/directory-list/directory-list.spec.json +2 -2
  49. package/agents/components/divider/divider.family.json +1 -1
  50. package/agents/components/divider/divider.md +12 -14
  51. package/agents/components/divider/divider.spec.json +8 -8
  52. package/agents/components/empty-state/empty-state.family.json +28 -0
  53. package/agents/components/empty-state/empty-state.md +69 -0
  54. package/agents/components/empty-state/empty-state.spec.json +87 -0
  55. package/agents/components/feed/ad.md +2 -4
  56. package/agents/components/feed/ad.spec.json +10 -10
  57. package/agents/components/feed/post.md +41 -43
  58. package/agents/components/feed/post.spec.json +35 -39
  59. package/agents/components/form-field/form-field.md +1 -1
  60. package/agents/components/form-field/input.md +32 -34
  61. package/agents/components/form-field/input.spec.json +39 -31
  62. package/agents/components/form-field/search.md +2 -4
  63. package/agents/components/form-field/search.spec.json +24 -16
  64. package/agents/components/form-field/select.md +18 -20
  65. package/agents/components/form-field/select.spec.json +36 -27
  66. package/agents/components/form-field/textarea.md +3 -5
  67. package/agents/components/form-field/textarea.spec.json +37 -29
  68. package/agents/components/header/main.md +4 -6
  69. package/agents/components/header/main.spec.json +3 -3
  70. package/agents/components/header/sub.md +6 -8
  71. package/agents/components/header/sub.spec.json +3 -3
  72. package/agents/components/list/accordion.md +34 -45
  73. package/agents/components/list/accordion.spec.json +26 -17
  74. package/agents/components/list/entry.md +59 -81
  75. package/agents/components/list/entry.spec.json +37 -21
  76. package/agents/components/list/list.md +2 -2
  77. package/agents/components/list/radio.md +13 -20
  78. package/agents/components/list/radio.spec.json +33 -18
  79. package/agents/components/list/standard.md +88 -64
  80. package/agents/components/list/standard.spec.json +52 -20
  81. package/agents/components/metadata/compact.md +4 -6
  82. package/agents/components/metadata/compact.spec.json +6 -6
  83. package/agents/components/metadata/metadata.md +1 -1
  84. package/agents/components/metadata/standard.md +12 -14
  85. package/agents/components/metadata/standard.spec.json +10 -10
  86. package/agents/components/nav-card/nav-card.md +25 -27
  87. package/agents/components/nav-card/nav-card.spec.json +25 -16
  88. package/agents/components/nav-list/nav-list.md +2 -8
  89. package/agents/components/nav-list/nav-list.spec.json +3 -3
  90. package/agents/components/navigation-bar/main.md +9 -11
  91. package/agents/components/navigation-bar/main.spec.json +6 -6
  92. package/agents/components/navigation-bar/search.md +6 -8
  93. package/agents/components/navigation-bar/search.spec.json +9 -9
  94. package/agents/components/navigation-bar/sub.md +9 -11
  95. package/agents/components/navigation-bar/sub.spec.json +7 -7
  96. package/agents/components/page-shell/page-shell.family.json +1 -1
  97. package/agents/components/page-shell/page-shell.md +33 -0
  98. package/agents/components/page-shell/page-shell.spec.json +85 -0
  99. package/agents/components/pagination/pagination.family.json +1 -1
  100. package/agents/components/pagination/pagination.md +3 -3
  101. package/agents/components/pagination/pagination.spec.json +5 -5
  102. package/agents/components/profile-header/profile-header.md +9 -11
  103. package/agents/components/profile-header/profile-header.spec.json +9 -9
  104. package/agents/components/progress/progress.family.json +1 -1
  105. package/agents/components/progress/progress.md +5 -5
  106. package/agents/components/progress/progress.spec.json +8 -8
  107. package/agents/components/side-sheet/side-sheet.md +11 -13
  108. package/agents/components/side-sheet/side-sheet.spec.json +3 -3
  109. package/agents/components/skeleton/skeleton.md +7 -9
  110. package/agents/components/skeleton/skeleton.spec.json +5 -5
  111. package/agents/components/spinner/spinner.family.json +27 -0
  112. package/agents/components/spinner/spinner.md +96 -0
  113. package/agents/components/spinner/spinner.spec.json +82 -0
  114. package/agents/components/status-tag/status-tag.md +7 -9
  115. package/agents/components/status-tag/status-tag.spec.json +5 -5
  116. package/agents/components/suggestion-list/suggestion-list.md +3 -7
  117. package/agents/components/suggestion-list/suggestion-list.spec.json +8 -12
  118. package/agents/components/switch/switch.md +12 -14
  119. package/agents/components/switch/switch.spec.json +23 -15
  120. package/agents/components/tab-bar/tab-bar.md +9 -11
  121. package/agents/components/tab-bar/tab-bar.spec.json +37 -23
  122. package/agents/components/tabs/rounded.md +6 -8
  123. package/agents/components/tabs/rounded.spec.json +34 -13
  124. package/agents/components/tabs/segmented.md +4 -6
  125. package/agents/components/tabs/segmented.spec.json +4 -8
  126. package/agents/components/tabs/underline.md +9 -11
  127. package/agents/components/tabs/underline.spec.json +31 -14
  128. package/agents/components/thumbnail/thumbnail.md +5 -7
  129. package/agents/components/thumbnail/thumbnail.spec.json +8 -8
  130. package/agents/components/toast/toast.md +5 -7
  131. package/agents/components/toast/toast.spec.json +3 -3
  132. package/agents/components/tooltip/tooltip.md +6 -8
  133. package/agents/components/tooltip/tooltip.spec.json +4 -4
  134. package/agents/manifest.json +8 -6
  135. package/agents/tokens.usage.json +71 -226
  136. package/agents/usage.json +12 -0
  137. package/dist/index.cjs +531 -262
  138. package/dist/index.cjs.map +1 -1
  139. package/dist/index.d.cts +57 -13
  140. package/dist/index.d.ts +57 -13
  141. package/dist/index.js +530 -263
  142. package/dist/index.js.map +1 -1
  143. package/dist/styles.css +560 -379
  144. package/eslint/rules.js +7 -7
  145. package/package.json +2 -3
  146. package/agents/anti-patterns.md +0 -533
  147. package/agents/compose.md +0 -240
  148. package/agents/images.md +0 -66
package/dist/index.d.cts CHANGED
@@ -32,8 +32,10 @@ export type BadgeProps =
32
32
  // ── Banner (banner/banner) ──
33
33
  interface BannerPropsOwn {
34
34
  appearance?: "default" | "accent" | "destructive";
35
- /** Paints a `sys.borderWidth.hairline` (1) inset stroke around the container, toned to the appearance's color family and kept deliberately faint so it reads as a soft edge of the same tint, not a frame — the subtle gray hairline (`sys.color.outlineVariant`) on `default`'s gray-tinted fill, `primary` at 40% (`color-mix(sys.color.primary, 40%)`) on `accent`'s blue-tinted fill, `error` at 40% on `destructive`. Rendered as an inset box-shadow, never a real border, so toggling it cannot change the banner's footprint (see DESIGN.md → Border & Stroke). Reach for it when the tinted fill alone doesn't separate the banner from its host surface. */
35
+ /** Paints a `sys.borderWidth.hairline` (1) inset stroke around the container, toned to the appearance's color family and kept deliberately faint so it reads as a soft edge of the same tint, not a frame — the subtle gray hairline (`sys.color.border.default`) on `default`'s gray-tinted fill, `primary` at 40% (`color-mix(sys.color.background.primary, 40%)`) on `accent`'s blue-tinted fill, `error` at 40% on `destructive`. Rendered as an inset box-shadow, never a real border, so toggling it cannot change the banner's footprint (see DESIGN.md → Border & Stroke). Reach for it when the tinted fill alone doesn't separate the banner from its host surface. */
36
36
  outlined?: boolean;
37
+ /** On `accent`, paints the title + body in the neutral default foreground (`sys.color.text.default`) and steps the action to `sys.color.background.primary` — i.e. the **Default appearance's** foreground treatment laid over the accent fill, decoupling the background tone from the text tone. Reach for it when the `primaryContainer` tint should still pull the eye but the copy should read as quiet, high-legibility body text rather than tonal `onPrimaryContainer` primary-family text (long-form explainers, dense asides). No effect on `default` (already `onSurface`) or `destructive` (the warning tone must carry through the copy). */
38
+ neutralBody?: boolean;
37
39
  /** Optional heading line above the body. label.md (14 / Semibold 600) in the container's foreground, separated from the body by `sys.layout.stack.2xs` (4). Reach for it when the aside needs a scannable lead-in; omit for single-thought asides where the body carries itself. */
38
40
  title?: React.ReactNode;
39
41
  /** A 16 × 16 (`sys.icon.md`) glyph at the container's leading edge. Inherits the banner's foreground (`currentColor`) so the mark reads as part of the body copy. The slot occupies the body.sm line-box height so the glyph centers on the **first line** of the body — multi-line bodies keep the icon anchored to the first-line cap, not the block center. Ignored when `thumbnail` is also passed. */
@@ -44,6 +46,8 @@ interface BannerPropsOwn {
44
46
  thumbnail?: React.ReactNode;
45
47
  /** { label, href? , onClick? } — a follow-through link rendered as a block child below the body. */
46
48
  action?: Record<string, unknown>;
49
+ /** A [Text Button](../button/text.md) (`<Button variant="text">`) rendered at the container's trailing edge, vertically centered against the whole block (`align-self: center`). Distinct from `action` (a follow-through link below the body): `trailingAction` is a compact inline commit that sits beside the copy — Dismiss, Enable, Undo. The button keeps full control of its own `size` and `appearance` per the button/text spec; **by default pick the appearance whose color family matches the banner fill** so the commit reads as part of the tinted block — `accent` banner → `appearance="accent"`, `default` banner → `appearance="default"`, `destructive` banner → the Text Button `destructive` flavor. Override only when a denser rung (`size="small"` / `"xsmall"`) or a different emphasis is deliberately wanted. The button also keeps its own `leadingIcon` / `trailingIcon` slots, so the commit can carry an in-button glyph (e.g. a trailing `ChevronRightIcon` on an *Enable* / *Continue* commit). Takes precedence over the banner-level `trailingIcon` when both are passed. */
50
+ trailingAction?: React.ReactNode;
47
51
  /** Body text — the explanation copy. */
48
52
  children: React.ReactNode;
49
53
  }
@@ -263,6 +267,19 @@ interface DirectoryListPropsOwn {
263
267
  }
264
268
  export interface DirectoryListProps extends Omit<React.HTMLAttributes<HTMLElement>, keyof DirectoryListPropsOwn>, DirectoryListPropsOwn {}
265
269
 
270
+ // ── EmptyState (empty-state/empty-state) ──
271
+ interface EmptyStatePropsOwn {
272
+ /** Optional leading glyph or illustration, centered above the headline. Sized to a `ref.space.600` (48) box — larger than `sys.icon.lg` (24), realizing DESIGN.md's `icon.xl` or larger intent (no `icon.xl` icon-size rung exists; the icon scale stops at `lg`). Painted in `sys.color.text.subtle` via `currentColor` so it reads as quiet, monochrome chrome — illustrations stay monochrome unless they carry deliberate brand-moment intent. Separated from the headline by `sys.layout.stack.sm` (12). */
273
+ illustration?: React.ReactNode;
274
+ /** The required lead line. `sys.typo.heading.sm` in `sys.color.text.default`. Names what the surface is for / why it is empty in one short line (e.g. 'No posts yet'). */
275
+ headline: React.ReactNode;
276
+ /** Optional supporting line below the headline. `sys.typo.body.sm` in `sys.color.text.subtle`, separated from the headline by `sys.layout.stack.2xs` (4). One sentence — the second of the three lines (e.g. 'Conversations you start or join will appear here'). */
277
+ body?: React.ReactNode;
278
+ /** { label, href?, onClick? } — the primary CTA. Renders a default-size primary `Button` (the surface's primary action — the one thing that fills the empty surface). Placed below the body with a `sys.layout.stack.md` (16) gap. There is NO `cta` slot to fill with a custom button; pass the action object so the primary Button is composed for you. */
279
+ action?: Record<string, unknown>;
280
+ }
281
+ export interface EmptyStateProps extends Omit<React.HTMLAttributes<HTMLDivElement>, keyof EmptyStatePropsOwn>, EmptyStatePropsOwn {}
282
+
266
283
  // ── Feed (feed/post) ──
267
284
  interface FeedPropsOwn {
268
285
  /** Editorial label — 'HOT', 'NEW', 'PINNED'. Opt-in; most posts render without one. */
@@ -287,7 +304,7 @@ interface FeedPropsOwn {
287
304
  thumbnail?: Record<string, unknown>;
288
305
  /** { label, participants } — inline poll banner. `label` is constrained to the literal `"Poll"` (the banner is editorial — see `tagBanner.labelEnum`); `participants` is the participant count shown after the divider. */
289
306
  poll?: Record<string, unknown>;
290
- /** { label, participants } — inline offer-evaluation banner. Same chrome as `poll` (surfaceVariant slab, leading glyph + label + divider + participant count), but the leading icon is `CompensationFillIcon` and the glyph + label paint in `sys.color.success` (resolves to `ref.palette.green.500`). Surfaces a 'compensation / offer evaluation' post — the author publishes their current salary or a competing offer and asks the community for better-option signals. `label` is constrained to the literal `"Offer"` (see `tagBanner.labelEnum`); `participants` is the participant count shown after the divider. */
307
+ /** { label, participants } — inline offer-evaluation banner. Same chrome as `poll` (surfaceVariant slab, leading glyph + label + divider + participant count), but the leading icon is `CompensationFillIcon` and the glyph + label paint in `sys.color.text.success` (resolves to `ref.palette.green.500`). Surfaces a 'compensation / offer evaluation' post — the author publishes their current salary or a competing offer and asks the community for better-option signals. `label` is constrained to the literal `"Offer"` (see `tagBanner.labelEnum`); `participants` is the participant count shown after the divider. */
291
308
  offer?: Record<string, unknown>;
292
309
  /** { title, source, src? } — inline link-share card. */
293
310
  citation?: Record<string, unknown>;
@@ -328,7 +345,7 @@ interface FormFieldInputPropsOwn {
328
345
  placeholder?: string;
329
346
  /** Visible label rendered above the field box and associated with it (`<label htmlFor>`). */
330
347
  label?: React.ReactNode;
331
- /** Assistive text rendered below the field box, left-aligned. **Optional on every appearance** — omit the prop to render the field without an assistive rung; the box and label keep their footprint. On the `error` appearance the helper re-tones to `sys.color.error` so the message reads as the error caption; an error field may still be shown without a helper, in which case only the field box re-tones. Mutually exclusive with `maxLength` — if both are given, the character count is shown and `helper` is ignored. */
348
+ /** Assistive text rendered below the field box, left-aligned. **Optional on every appearance** — omit the prop to render the field without an assistive rung; the box and label keep their footprint. On the `error` appearance the helper re-tones to `sys.color.text.danger` so the message reads as the error caption; an error field may still be shown without a helper, in which case only the field box re-tones. Mutually exclusive with `maxLength` — if both are given, the character count is shown and `helper` is ignored. */
332
349
  helper?: React.ReactNode;
333
350
  /** Caps the input length and renders a `current/max` character count below the field box, right-aligned. Mutually exclusive with `helper`. */
334
351
  maxLength?: number;
@@ -344,7 +361,7 @@ interface FormFieldTextareaPropsOwn {
344
361
  placeholder?: string;
345
362
  /** Visible label rendered above the field box and associated with it (`<label htmlFor>`). */
346
363
  label?: React.ReactNode;
347
- /** Assistive text rendered below the field box, left-aligned. Same rules as [input.helper](./input.md): mutually exclusive with `maxLength`, optional on every appearance, re-tones to `sys.color.error` on the error appearance. */
364
+ /** Assistive text rendered below the field box, left-aligned. Same rules as [input.helper](./input.md): mutually exclusive with `maxLength`, optional on every appearance, re-tones to `sys.color.text.danger` on the error appearance. */
348
365
  helper?: React.ReactNode;
349
366
  /** Caps the value length and renders a `current/max` character count below the box, right-aligned. Mutually exclusive with `helper`. */
350
367
  maxLength?: number;
@@ -373,7 +390,7 @@ interface FormFieldSelectPropsOwn {
373
390
  placeholder?: string;
374
391
  label?: React.ReactNode;
375
392
  helper?: React.ReactNode;
376
- /** Optional 16px (`sys.icon.md`) decorative glyph pinned at the inner-left edge of the field. Tracks the field's active text colour (`sys.color.onSurface` on the default appearance, `sys.color.onErrorContainer` on `error`) so the glyph reads as part of the typed content. */
393
+ /** Optional 16px (`sys.icon.md`) decorative glyph pinned at the inner-left edge of the field. Tracks the field's active text colour (`sys.color.text.default` on the default appearance, `sys.color.text.danger` on `error`) so the glyph reads as part of the typed content. */
377
394
  leadingIcon?: React.ReactNode;
378
395
  /** Fired when the field box or the trailing chevron is clicked. Consumers use this to raise a `BottomSheet` with the option list. */
379
396
  onOpen?: (...args: any[]) => any;
@@ -486,7 +503,7 @@ interface MetadataStandardPropsOwn {
486
503
  name: string;
487
504
  /** Destination URL for the entity name. When present, the name renders as an `<a>`; otherwise as a `<span>`. */
488
505
  nameHref?: string;
489
- /** Inline timestamp painted after the name on the primary line, in `label.sm` / `sys.color.outline` — one tonal step further than the name. Reach for it on Feed Post; omit on Feed Ad. */
506
+ /** Inline timestamp painted after the name on the primary line, in `label.sm` / `sys.color.border.boldest` — one tonal step further than the name. Reach for it on Feed Post; omit on Feed Ad. */
490
507
  timestamp?: string;
491
508
  /** Whether to paint the inline follow toggle after the timestamp. When `true`, a dot separator + Follow/Following text toggle render at the primary line's trailing edge. State is controlled via `followed` + `onFollowChange`. */
492
509
  followAction?: boolean;
@@ -509,7 +526,7 @@ interface MetadataCompactPropsOwn {
509
526
  avatar?: Record<string, unknown>;
510
527
  /** Array of independently-linked identity items — canonical fill is `[company name, nickname]`, the nickname canonically last, displayed bare (no @ prefix). Each entry is either a string (renders as a stub-href link) or a `{ label, href, badge }` object — `badge` is an optional SINGLE presentational mark node rendered AFTER the item's link, outside the <a> (canonical fill: Badge variant="role" on the trailing nickname item). Items separate by middot. Same grammar as the standard sub's `meta`; here it is the whole cluster, so it is required. */
511
528
  meta: React.ReactNode;
512
- /** Posting time at the line's trailing edge — plain text (never a link), in `label.sm` / `sys.color.outline` so it recedes behind the identity links. NOT preceded by a middot: the time is separated from the identity cluster by an `inline.md` (8) gap so it reads as a distinct trailing element rather than another identity item (mirrors the Standard head's name↔time treatment). Required: the timestamp is what distinguishes a compact attribution from a bare identity row. */
529
+ /** Posting time at the line's trailing edge — plain text (never a link), in `label.sm` / `sys.color.border.boldest` so it recedes behind the identity links. NOT preceded by a middot: the time is separated from the identity cluster by an `inline.md` (8) gap so it reads as a distinct trailing element rather than another identity item (mirrors the Standard head's name↔time treatment). Required: the timestamp is what distinguishes a compact attribution from a bare identity row. */
513
530
  timestamp: string;
514
531
  }
515
532
  export interface MetadataCompactProps extends Omit<React.HTMLAttributes<HTMLDivElement>, keyof MetadataCompactPropsOwn>, MetadataCompactPropsOwn {}
@@ -530,7 +547,7 @@ interface NavCardPropsOwn {
530
547
  leading?: React.ReactNode;
531
548
  /** Overrides the variant's trailing affordance. Pass a custom 16px glyph when the action isn't a navigation drill-in (inline expand-down arrow, external-link arrow). On `variant="default"` no trailing renders unless `trailingIcon` is supplied; on `variant="nav"` the auto-chevron is replaced. */
532
549
  trailingIcon?: React.ReactNode;
533
- /** Container fill. `default` is transparent — the card's identity is the outlined chrome (hairline + radius + label + chevron) and the host surface tone reads through. `surface` paints `sys.color.surface` so the card reads as its own opaque tier; reach for it when the card sits on a transparent / non-`surface` host (between bare-surface sections, on a tonal band the card needs to break out of). */
550
+ /** Container fill. `default` is transparent — the card's identity is the outlined chrome (hairline + radius + label + chevron) and the host surface tone reads through. `surface` paints `sys.color.surface.default` so the card reads as its own opaque tier; reach for it when the card sits on a transparent / non-`surface` host (between bare-surface sections, on a tonal band the card needs to break out of). */
534
551
  appearance?: "default" | "surface";
535
552
  /** When provided, renders as `<a href>` instead of `<button>`. Mutually exclusive with `onClick` as the primary commit, though either may be combined for analytics. */
536
553
  href?: string;
@@ -572,7 +589,7 @@ export interface NavigationBarMainProps extends Omit<React.HTMLAttributes<HTMLEl
572
589
 
573
590
  interface NavigationBarSubPropsOwn {
574
591
  variant: "sub";
575
- /** Container fill and foreground tone. `surface` (default) — opaque `sys.color.surface` fill, `onSurface` icons and title; the canonical page-chrome treatment. `overlay` — transparent container with **fixed white** icons (`ref.palette.white.1000`), used when the bar floats over a hero/cover image (e.g. inside [Profile header](../profile-header/profile-header.md)). In `overlay`, the title slot is intentionally muted — pass `title=""` (empty string) when the host already carries page identity below the bar. */
592
+ /** Container fill and foreground tone. `surface` (default) — opaque `sys.color.surface.default` fill, `onSurface` icons and title; the canonical page-chrome treatment. `overlay` — transparent container with **fixed white** icons (`ref.palette.white.1000`), used when the bar floats over a hero/cover image (e.g. inside [Profile header](../profile-header/profile-header.md)). In `overlay`, the title slot is intentionally muted — pass `title=""` (empty string) when the host already carries page identity below the bar. */
576
593
  appearance?: "surface" | "overlay";
577
594
  /** Page name. Optional — omit it for bars whose side actions say everything (canonical: the composer text-button pair, Cancel / Post). A title-less bar renders a non-heading placeholder in the centre cell so the side slots stay in their 1fr/auto/1fr columns. Pass an empty string in `overlay` appearance when the host (e.g. ProfileHeader) carries the identity directly below the bar. */
578
595
  title?: string;
@@ -610,6 +627,21 @@ export type NavigationBarProps =
610
627
  | NavigationBarSubProps
611
628
  | NavigationBarSearchProps;
612
629
 
630
+ // ── PageShell (page-shell/page-shell) ──
631
+ interface PageShellPropsOwn {
632
+ /** Rendered in flow at the top — a `NavigationBar`. Pays its own `safe-area-inset-top`; the shell does NOT re-pay it. Pinned by the flex column, never by `position: sticky` / `fixed`. */
633
+ nav?: React.ReactNode;
634
+ /** Rendered in flow at the bottom — a `TabBar`. Pays its own `safe-area-inset-bottom`; the shell does NOT re-pay it. Pinned by the flex column, never by `position: sticky` / `fixed`. */
635
+ tabBar?: React.ReactNode;
636
+ /** The scrolling body content — the sole scroll region. Rendered inside `<main class="chorus-page-shell__body">`. */
637
+ children: React.ReactNode;
638
+ /** Spread onto `<main>` — use to add a page gutter (`style={{ paddingInline: 'var(--sys-layout-page-md)' }}`) when the screen carries inline (non-full-bleed) content. `className` composes with `chorus-page-shell__body`; the rest spread as-is. Do NOT use it to re-pay a bar's safe-area inset. */
639
+ bodyProps?: Record<string, unknown>;
640
+ /** Composes with the shell root's own `chorus-page-shell` class. Use for placement only; never to override the pin/scroll mechanics. */
641
+ className?: string;
642
+ }
643
+ export interface PageShellProps extends Omit<React.HTMLAttributes<HTMLDivElement>, keyof PageShellPropsOwn>, PageShellPropsOwn {}
644
+
613
645
  // ── Pagination (pagination/pagination) ──
614
646
  interface PaginationPropsOwn {
615
647
  /** Total number of pages — one dot renders per page. Below 2 the component renders nothing. */
@@ -631,7 +663,7 @@ interface ProfileHeaderPropsOwn {
631
663
  visibility?: "public" | "private";
632
664
  /** Override the visibility label text — used for localisation (e.g. '공개' / '비공개'). When omitted, defaults to 'Public' / 'Private' based on `visibility`. */
633
665
  visibilityLabel?: string;
634
- /** Follower count line — formatted by the consumer so the unit / locale stays in their hands (e.g. '999 followers', '12.4K followers', '999 팔로워'). Paints in `sys.typo.body.sm` / `sys.color.onSurfaceVariant` to the right of a bullet separator on the meta row. */
666
+ /** Follower count line — formatted by the consumer so the unit / locale stays in their hands (e.g. '999 followers', '12.4K followers', '999 팔로워'). Paints in `sys.typo.body.sm` / `sys.color.text.subtle` to the right of a bullet separator on the meta row. */
635
667
  followers: string;
636
668
  /** Whether the viewer is following this entity. Drives the trailing [Toggle Button](../button/toggle.md): false → 'Follow' (primary fill); true → 'Following' (transparent fill + hairline outline so the committed state recedes against any host surface tier). */
637
669
  followed?: boolean;
@@ -713,6 +745,17 @@ interface SkeletonPropsOwn {
713
745
  }
714
746
  export interface SkeletonProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, keyof SkeletonPropsOwn>, SkeletonPropsOwn {}
715
747
 
748
+ // ── Spinner (spinner/spinner) ──
749
+ interface SpinnerPropsOwn {
750
+ /** Selects the arc diameter off the `icon.*` ladder. `medium` paints at `sys.icon.lg` (24px); `small` at `sys.icon.md` (16px). */
751
+ size?: "medium" | "small";
752
+ /** Optional loading copy rendered beside the arc in `sys.typo.body.sm` / `sys.color.text.subtle`. When present it also supplies the accessible name, so `aria-label` is not required. */
753
+ label?: React.ReactNode;
754
+ /** Accessible label announced by screen readers. Defaults to `'Loading'`. Supply a more specific name (e.g. `'Signing in'`) when the wait scope is meaningful. Redundant when a visible `label` is passed. */
755
+ "aria-label"?: string;
756
+ }
757
+ export interface SpinnerProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, keyof SpinnerPropsOwn>, SpinnerPropsOwn {}
758
+
716
759
  // ── StatusTag (status-tag/status-tag) ──
717
760
  interface StatusTagPropsOwn {
718
761
  /** Tonal fill / foreground pair. `neutral` is the quiet informational default; `error` is the rejection / blocked / failed state. */
@@ -807,7 +850,7 @@ interface ThumbnailPropsOwn {
807
850
  updateDot?: boolean;
808
851
  /** { src, alt } — 16×16 circular badge at the bottom-right. */
809
852
  logoBadge?: Record<string, unknown>;
810
- /** When `true`, paints a 2-token-wide `sys.color.surface` outline around the Thumbnail container as an outset halo. The outline reads as an isolation ring that separates the Thumbnail's circular edge from anything visually noisy underneath it. Painted as `box-shadow: 0 0 0 sys.borderWidth.thin sys.color.surface` so it never reflows the slot's intrinsic diameter — same no-layout-stroke idiom the rest of the system uses. Pair with hosts whose chrome reads as a `surface*` tier (so the halo blends in) and whose backdrop differs from that tier (so the halo actually separates). */
853
+ /** When `true`, paints a 2-token-wide `sys.color.surface.default` outline around the Thumbnail container as an outset halo. The outline reads as an isolation ring that separates the Thumbnail's circular edge from anything visually noisy underneath it. Painted as `box-shadow: 0 0 0 sys.borderWidth.thin sys.color.surface.default` so it never reflows the slot's intrinsic diameter — same no-layout-stroke idiom the rest of the system uses. Pair with hosts whose chrome reads as a `surface*` tier (so the halo blends in) and whose backdrop differs from that tier (so the halo actually separates). */
811
854
  outlined?: boolean;
812
855
  }
813
856
  export interface ThumbnailProps extends Omit<React.HTMLAttributes<HTMLDivElement>, keyof ThumbnailPropsOwn>, ThumbnailPropsOwn {}
@@ -838,7 +881,6 @@ export interface TooltipProps extends Omit<React.HTMLAttributes<HTMLDivElement>,
838
881
  export interface TabProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
839
882
  export interface FeedGroupProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
840
883
  export interface FormFieldGroupProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
841
- export interface PageShellProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
842
884
  export interface NavCardGroupProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
843
885
  export interface CarouselProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
844
886
  export interface SideSheetGroupProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
@@ -858,6 +900,7 @@ export const Chip: React.ForwardRefExoticComponent<ChipProps & React.RefAttribut
858
900
  export const Dialog: React.ForwardRefExoticComponent<DialogProps & React.RefAttributes<HTMLDivElement>>;
859
901
  export const Divider: React.ForwardRefExoticComponent<DividerProps & React.RefAttributes<HTMLElement>>;
860
902
  export const DirectoryList: React.ForwardRefExoticComponent<DirectoryListProps & React.RefAttributes<HTMLElement>>;
903
+ export const EmptyState: React.ForwardRefExoticComponent<EmptyStateProps & React.RefAttributes<HTMLDivElement>>;
861
904
  export const Feed: React.ForwardRefExoticComponent<FeedProps & React.RefAttributes<HTMLElement>>;
862
905
  export const FeedAd: React.ForwardRefExoticComponent<FeedAdProps & React.RefAttributes<HTMLElement>>;
863
906
  export const FormField: React.ForwardRefExoticComponent<FormFieldProps & React.RefAttributes<HTMLElement>>;
@@ -868,6 +911,7 @@ export const Metadata: React.ForwardRefExoticComponent<MetadataProps & React.Ref
868
911
  export const NavCard: React.ForwardRefExoticComponent<NavCardProps & React.RefAttributes<HTMLButtonElement>>;
869
912
  export const NavList: React.ForwardRefExoticComponent<NavListProps & React.RefAttributes<HTMLElement>>;
870
913
  export const NavigationBar: React.ForwardRefExoticComponent<NavigationBarProps & React.RefAttributes<HTMLElement>>;
914
+ export const PageShell: React.ForwardRefExoticComponent<PageShellProps & React.RefAttributes<HTMLDivElement>>;
871
915
  export const Pagination: React.ForwardRefExoticComponent<PaginationProps & React.RefAttributes<HTMLSpanElement>>;
872
916
  export const ProfileHeader: React.ForwardRefExoticComponent<ProfileHeaderProps & React.RefAttributes<HTMLElement>>;
873
917
  export const Progress: React.ForwardRefExoticComponent<ProgressProps & React.RefAttributes<HTMLDivElement>>;
@@ -875,6 +919,7 @@ export const PostCarousel: React.ForwardRefExoticComponent<PostCarouselProps & R
875
919
  export const ProfileCarousel: React.ForwardRefExoticComponent<ProfileCarouselProps & React.RefAttributes<HTMLDivElement>>;
876
920
  export const SideSheet: React.ForwardRefExoticComponent<SideSheetProps & React.RefAttributes<HTMLElement>>;
877
921
  export const Skeleton: React.ForwardRefExoticComponent<SkeletonProps & React.RefAttributes<HTMLSpanElement>>;
922
+ export const Spinner: React.ForwardRefExoticComponent<SpinnerProps & React.RefAttributes<HTMLSpanElement>>;
878
923
  export const StatusTag: React.ForwardRefExoticComponent<StatusTagProps & React.RefAttributes<HTMLSpanElement>>;
879
924
  export const Switch: React.ForwardRefExoticComponent<SwitchProps & React.RefAttributes<HTMLButtonElement>>;
880
925
  export const TabBar: React.ForwardRefExoticComponent<TabBarProps & React.RefAttributes<HTMLElement>>;
@@ -885,7 +930,6 @@ export const Tooltip: React.ForwardRefExoticComponent<TooltipProps & React.RefAt
885
930
  export const Tab: React.ForwardRefExoticComponent<TabProps & React.RefAttributes<HTMLElement>>;
886
931
  export const FeedGroup: React.ForwardRefExoticComponent<FeedGroupProps & React.RefAttributes<HTMLElement>>;
887
932
  export const FormFieldGroup: React.ForwardRefExoticComponent<FormFieldGroupProps & React.RefAttributes<HTMLElement>>;
888
- export const PageShell: React.ForwardRefExoticComponent<PageShellProps & React.RefAttributes<HTMLElement>>;
889
933
  export const NavCardGroup: React.ForwardRefExoticComponent<NavCardGroupProps & React.RefAttributes<HTMLElement>>;
890
934
  export const Carousel: React.ForwardRefExoticComponent<CarouselProps & React.RefAttributes<HTMLElement>>;
891
935
  export const SideSheetGroup: React.ForwardRefExoticComponent<SideSheetGroupProps & React.RefAttributes<HTMLElement>>;
package/dist/index.d.ts CHANGED
@@ -32,8 +32,10 @@ export type BadgeProps =
32
32
  // ── Banner (banner/banner) ──
33
33
  interface BannerPropsOwn {
34
34
  appearance?: "default" | "accent" | "destructive";
35
- /** Paints a `sys.borderWidth.hairline` (1) inset stroke around the container, toned to the appearance's color family and kept deliberately faint so it reads as a soft edge of the same tint, not a frame — the subtle gray hairline (`sys.color.outlineVariant`) on `default`'s gray-tinted fill, `primary` at 40% (`color-mix(sys.color.primary, 40%)`) on `accent`'s blue-tinted fill, `error` at 40% on `destructive`. Rendered as an inset box-shadow, never a real border, so toggling it cannot change the banner's footprint (see DESIGN.md → Border & Stroke). Reach for it when the tinted fill alone doesn't separate the banner from its host surface. */
35
+ /** Paints a `sys.borderWidth.hairline` (1) inset stroke around the container, toned to the appearance's color family and kept deliberately faint so it reads as a soft edge of the same tint, not a frame — the subtle gray hairline (`sys.color.border.default`) on `default`'s gray-tinted fill, `primary` at 40% (`color-mix(sys.color.background.primary, 40%)`) on `accent`'s blue-tinted fill, `error` at 40% on `destructive`. Rendered as an inset box-shadow, never a real border, so toggling it cannot change the banner's footprint (see DESIGN.md → Border & Stroke). Reach for it when the tinted fill alone doesn't separate the banner from its host surface. */
36
36
  outlined?: boolean;
37
+ /** On `accent`, paints the title + body in the neutral default foreground (`sys.color.text.default`) and steps the action to `sys.color.background.primary` — i.e. the **Default appearance's** foreground treatment laid over the accent fill, decoupling the background tone from the text tone. Reach for it when the `primaryContainer` tint should still pull the eye but the copy should read as quiet, high-legibility body text rather than tonal `onPrimaryContainer` primary-family text (long-form explainers, dense asides). No effect on `default` (already `onSurface`) or `destructive` (the warning tone must carry through the copy). */
38
+ neutralBody?: boolean;
37
39
  /** Optional heading line above the body. label.md (14 / Semibold 600) in the container's foreground, separated from the body by `sys.layout.stack.2xs` (4). Reach for it when the aside needs a scannable lead-in; omit for single-thought asides where the body carries itself. */
38
40
  title?: React.ReactNode;
39
41
  /** A 16 × 16 (`sys.icon.md`) glyph at the container's leading edge. Inherits the banner's foreground (`currentColor`) so the mark reads as part of the body copy. The slot occupies the body.sm line-box height so the glyph centers on the **first line** of the body — multi-line bodies keep the icon anchored to the first-line cap, not the block center. Ignored when `thumbnail` is also passed. */
@@ -44,6 +46,8 @@ interface BannerPropsOwn {
44
46
  thumbnail?: React.ReactNode;
45
47
  /** { label, href? , onClick? } — a follow-through link rendered as a block child below the body. */
46
48
  action?: Record<string, unknown>;
49
+ /** A [Text Button](../button/text.md) (`<Button variant="text">`) rendered at the container's trailing edge, vertically centered against the whole block (`align-self: center`). Distinct from `action` (a follow-through link below the body): `trailingAction` is a compact inline commit that sits beside the copy — Dismiss, Enable, Undo. The button keeps full control of its own `size` and `appearance` per the button/text spec; **by default pick the appearance whose color family matches the banner fill** so the commit reads as part of the tinted block — `accent` banner → `appearance="accent"`, `default` banner → `appearance="default"`, `destructive` banner → the Text Button `destructive` flavor. Override only when a denser rung (`size="small"` / `"xsmall"`) or a different emphasis is deliberately wanted. The button also keeps its own `leadingIcon` / `trailingIcon` slots, so the commit can carry an in-button glyph (e.g. a trailing `ChevronRightIcon` on an *Enable* / *Continue* commit). Takes precedence over the banner-level `trailingIcon` when both are passed. */
50
+ trailingAction?: React.ReactNode;
47
51
  /** Body text — the explanation copy. */
48
52
  children: React.ReactNode;
49
53
  }
@@ -263,6 +267,19 @@ interface DirectoryListPropsOwn {
263
267
  }
264
268
  export interface DirectoryListProps extends Omit<React.HTMLAttributes<HTMLElement>, keyof DirectoryListPropsOwn>, DirectoryListPropsOwn {}
265
269
 
270
+ // ── EmptyState (empty-state/empty-state) ──
271
+ interface EmptyStatePropsOwn {
272
+ /** Optional leading glyph or illustration, centered above the headline. Sized to a `ref.space.600` (48) box — larger than `sys.icon.lg` (24), realizing DESIGN.md's `icon.xl` or larger intent (no `icon.xl` icon-size rung exists; the icon scale stops at `lg`). Painted in `sys.color.text.subtle` via `currentColor` so it reads as quiet, monochrome chrome — illustrations stay monochrome unless they carry deliberate brand-moment intent. Separated from the headline by `sys.layout.stack.sm` (12). */
273
+ illustration?: React.ReactNode;
274
+ /** The required lead line. `sys.typo.heading.sm` in `sys.color.text.default`. Names what the surface is for / why it is empty in one short line (e.g. 'No posts yet'). */
275
+ headline: React.ReactNode;
276
+ /** Optional supporting line below the headline. `sys.typo.body.sm` in `sys.color.text.subtle`, separated from the headline by `sys.layout.stack.2xs` (4). One sentence — the second of the three lines (e.g. 'Conversations you start or join will appear here'). */
277
+ body?: React.ReactNode;
278
+ /** { label, href?, onClick? } — the primary CTA. Renders a default-size primary `Button` (the surface's primary action — the one thing that fills the empty surface). Placed below the body with a `sys.layout.stack.md` (16) gap. There is NO `cta` slot to fill with a custom button; pass the action object so the primary Button is composed for you. */
279
+ action?: Record<string, unknown>;
280
+ }
281
+ export interface EmptyStateProps extends Omit<React.HTMLAttributes<HTMLDivElement>, keyof EmptyStatePropsOwn>, EmptyStatePropsOwn {}
282
+
266
283
  // ── Feed (feed/post) ──
267
284
  interface FeedPropsOwn {
268
285
  /** Editorial label — 'HOT', 'NEW', 'PINNED'. Opt-in; most posts render without one. */
@@ -287,7 +304,7 @@ interface FeedPropsOwn {
287
304
  thumbnail?: Record<string, unknown>;
288
305
  /** { label, participants } — inline poll banner. `label` is constrained to the literal `"Poll"` (the banner is editorial — see `tagBanner.labelEnum`); `participants` is the participant count shown after the divider. */
289
306
  poll?: Record<string, unknown>;
290
- /** { label, participants } — inline offer-evaluation banner. Same chrome as `poll` (surfaceVariant slab, leading glyph + label + divider + participant count), but the leading icon is `CompensationFillIcon` and the glyph + label paint in `sys.color.success` (resolves to `ref.palette.green.500`). Surfaces a 'compensation / offer evaluation' post — the author publishes their current salary or a competing offer and asks the community for better-option signals. `label` is constrained to the literal `"Offer"` (see `tagBanner.labelEnum`); `participants` is the participant count shown after the divider. */
307
+ /** { label, participants } — inline offer-evaluation banner. Same chrome as `poll` (surfaceVariant slab, leading glyph + label + divider + participant count), but the leading icon is `CompensationFillIcon` and the glyph + label paint in `sys.color.text.success` (resolves to `ref.palette.green.500`). Surfaces a 'compensation / offer evaluation' post — the author publishes their current salary or a competing offer and asks the community for better-option signals. `label` is constrained to the literal `"Offer"` (see `tagBanner.labelEnum`); `participants` is the participant count shown after the divider. */
291
308
  offer?: Record<string, unknown>;
292
309
  /** { title, source, src? } — inline link-share card. */
293
310
  citation?: Record<string, unknown>;
@@ -328,7 +345,7 @@ interface FormFieldInputPropsOwn {
328
345
  placeholder?: string;
329
346
  /** Visible label rendered above the field box and associated with it (`<label htmlFor>`). */
330
347
  label?: React.ReactNode;
331
- /** Assistive text rendered below the field box, left-aligned. **Optional on every appearance** — omit the prop to render the field without an assistive rung; the box and label keep their footprint. On the `error` appearance the helper re-tones to `sys.color.error` so the message reads as the error caption; an error field may still be shown without a helper, in which case only the field box re-tones. Mutually exclusive with `maxLength` — if both are given, the character count is shown and `helper` is ignored. */
348
+ /** Assistive text rendered below the field box, left-aligned. **Optional on every appearance** — omit the prop to render the field without an assistive rung; the box and label keep their footprint. On the `error` appearance the helper re-tones to `sys.color.text.danger` so the message reads as the error caption; an error field may still be shown without a helper, in which case only the field box re-tones. Mutually exclusive with `maxLength` — if both are given, the character count is shown and `helper` is ignored. */
332
349
  helper?: React.ReactNode;
333
350
  /** Caps the input length and renders a `current/max` character count below the field box, right-aligned. Mutually exclusive with `helper`. */
334
351
  maxLength?: number;
@@ -344,7 +361,7 @@ interface FormFieldTextareaPropsOwn {
344
361
  placeholder?: string;
345
362
  /** Visible label rendered above the field box and associated with it (`<label htmlFor>`). */
346
363
  label?: React.ReactNode;
347
- /** Assistive text rendered below the field box, left-aligned. Same rules as [input.helper](./input.md): mutually exclusive with `maxLength`, optional on every appearance, re-tones to `sys.color.error` on the error appearance. */
364
+ /** Assistive text rendered below the field box, left-aligned. Same rules as [input.helper](./input.md): mutually exclusive with `maxLength`, optional on every appearance, re-tones to `sys.color.text.danger` on the error appearance. */
348
365
  helper?: React.ReactNode;
349
366
  /** Caps the value length and renders a `current/max` character count below the box, right-aligned. Mutually exclusive with `helper`. */
350
367
  maxLength?: number;
@@ -373,7 +390,7 @@ interface FormFieldSelectPropsOwn {
373
390
  placeholder?: string;
374
391
  label?: React.ReactNode;
375
392
  helper?: React.ReactNode;
376
- /** Optional 16px (`sys.icon.md`) decorative glyph pinned at the inner-left edge of the field. Tracks the field's active text colour (`sys.color.onSurface` on the default appearance, `sys.color.onErrorContainer` on `error`) so the glyph reads as part of the typed content. */
393
+ /** Optional 16px (`sys.icon.md`) decorative glyph pinned at the inner-left edge of the field. Tracks the field's active text colour (`sys.color.text.default` on the default appearance, `sys.color.text.danger` on `error`) so the glyph reads as part of the typed content. */
377
394
  leadingIcon?: React.ReactNode;
378
395
  /** Fired when the field box or the trailing chevron is clicked. Consumers use this to raise a `BottomSheet` with the option list. */
379
396
  onOpen?: (...args: any[]) => any;
@@ -486,7 +503,7 @@ interface MetadataStandardPropsOwn {
486
503
  name: string;
487
504
  /** Destination URL for the entity name. When present, the name renders as an `<a>`; otherwise as a `<span>`. */
488
505
  nameHref?: string;
489
- /** Inline timestamp painted after the name on the primary line, in `label.sm` / `sys.color.outline` — one tonal step further than the name. Reach for it on Feed Post; omit on Feed Ad. */
506
+ /** Inline timestamp painted after the name on the primary line, in `label.sm` / `sys.color.border.boldest` — one tonal step further than the name. Reach for it on Feed Post; omit on Feed Ad. */
490
507
  timestamp?: string;
491
508
  /** Whether to paint the inline follow toggle after the timestamp. When `true`, a dot separator + Follow/Following text toggle render at the primary line's trailing edge. State is controlled via `followed` + `onFollowChange`. */
492
509
  followAction?: boolean;
@@ -509,7 +526,7 @@ interface MetadataCompactPropsOwn {
509
526
  avatar?: Record<string, unknown>;
510
527
  /** Array of independently-linked identity items — canonical fill is `[company name, nickname]`, the nickname canonically last, displayed bare (no @ prefix). Each entry is either a string (renders as a stub-href link) or a `{ label, href, badge }` object — `badge` is an optional SINGLE presentational mark node rendered AFTER the item's link, outside the <a> (canonical fill: Badge variant="role" on the trailing nickname item). Items separate by middot. Same grammar as the standard sub's `meta`; here it is the whole cluster, so it is required. */
511
528
  meta: React.ReactNode;
512
- /** Posting time at the line's trailing edge — plain text (never a link), in `label.sm` / `sys.color.outline` so it recedes behind the identity links. NOT preceded by a middot: the time is separated from the identity cluster by an `inline.md` (8) gap so it reads as a distinct trailing element rather than another identity item (mirrors the Standard head's name↔time treatment). Required: the timestamp is what distinguishes a compact attribution from a bare identity row. */
529
+ /** Posting time at the line's trailing edge — plain text (never a link), in `label.sm` / `sys.color.border.boldest` so it recedes behind the identity links. NOT preceded by a middot: the time is separated from the identity cluster by an `inline.md` (8) gap so it reads as a distinct trailing element rather than another identity item (mirrors the Standard head's name↔time treatment). Required: the timestamp is what distinguishes a compact attribution from a bare identity row. */
513
530
  timestamp: string;
514
531
  }
515
532
  export interface MetadataCompactProps extends Omit<React.HTMLAttributes<HTMLDivElement>, keyof MetadataCompactPropsOwn>, MetadataCompactPropsOwn {}
@@ -530,7 +547,7 @@ interface NavCardPropsOwn {
530
547
  leading?: React.ReactNode;
531
548
  /** Overrides the variant's trailing affordance. Pass a custom 16px glyph when the action isn't a navigation drill-in (inline expand-down arrow, external-link arrow). On `variant="default"` no trailing renders unless `trailingIcon` is supplied; on `variant="nav"` the auto-chevron is replaced. */
532
549
  trailingIcon?: React.ReactNode;
533
- /** Container fill. `default` is transparent — the card's identity is the outlined chrome (hairline + radius + label + chevron) and the host surface tone reads through. `surface` paints `sys.color.surface` so the card reads as its own opaque tier; reach for it when the card sits on a transparent / non-`surface` host (between bare-surface sections, on a tonal band the card needs to break out of). */
550
+ /** Container fill. `default` is transparent — the card's identity is the outlined chrome (hairline + radius + label + chevron) and the host surface tone reads through. `surface` paints `sys.color.surface.default` so the card reads as its own opaque tier; reach for it when the card sits on a transparent / non-`surface` host (between bare-surface sections, on a tonal band the card needs to break out of). */
534
551
  appearance?: "default" | "surface";
535
552
  /** When provided, renders as `<a href>` instead of `<button>`. Mutually exclusive with `onClick` as the primary commit, though either may be combined for analytics. */
536
553
  href?: string;
@@ -572,7 +589,7 @@ export interface NavigationBarMainProps extends Omit<React.HTMLAttributes<HTMLEl
572
589
 
573
590
  interface NavigationBarSubPropsOwn {
574
591
  variant: "sub";
575
- /** Container fill and foreground tone. `surface` (default) — opaque `sys.color.surface` fill, `onSurface` icons and title; the canonical page-chrome treatment. `overlay` — transparent container with **fixed white** icons (`ref.palette.white.1000`), used when the bar floats over a hero/cover image (e.g. inside [Profile header](../profile-header/profile-header.md)). In `overlay`, the title slot is intentionally muted — pass `title=""` (empty string) when the host already carries page identity below the bar. */
592
+ /** Container fill and foreground tone. `surface` (default) — opaque `sys.color.surface.default` fill, `onSurface` icons and title; the canonical page-chrome treatment. `overlay` — transparent container with **fixed white** icons (`ref.palette.white.1000`), used when the bar floats over a hero/cover image (e.g. inside [Profile header](../profile-header/profile-header.md)). In `overlay`, the title slot is intentionally muted — pass `title=""` (empty string) when the host already carries page identity below the bar. */
576
593
  appearance?: "surface" | "overlay";
577
594
  /** Page name. Optional — omit it for bars whose side actions say everything (canonical: the composer text-button pair, Cancel / Post). A title-less bar renders a non-heading placeholder in the centre cell so the side slots stay in their 1fr/auto/1fr columns. Pass an empty string in `overlay` appearance when the host (e.g. ProfileHeader) carries the identity directly below the bar. */
578
595
  title?: string;
@@ -610,6 +627,21 @@ export type NavigationBarProps =
610
627
  | NavigationBarSubProps
611
628
  | NavigationBarSearchProps;
612
629
 
630
+ // ── PageShell (page-shell/page-shell) ──
631
+ interface PageShellPropsOwn {
632
+ /** Rendered in flow at the top — a `NavigationBar`. Pays its own `safe-area-inset-top`; the shell does NOT re-pay it. Pinned by the flex column, never by `position: sticky` / `fixed`. */
633
+ nav?: React.ReactNode;
634
+ /** Rendered in flow at the bottom — a `TabBar`. Pays its own `safe-area-inset-bottom`; the shell does NOT re-pay it. Pinned by the flex column, never by `position: sticky` / `fixed`. */
635
+ tabBar?: React.ReactNode;
636
+ /** The scrolling body content — the sole scroll region. Rendered inside `<main class="chorus-page-shell__body">`. */
637
+ children: React.ReactNode;
638
+ /** Spread onto `<main>` — use to add a page gutter (`style={{ paddingInline: 'var(--sys-layout-page-md)' }}`) when the screen carries inline (non-full-bleed) content. `className` composes with `chorus-page-shell__body`; the rest spread as-is. Do NOT use it to re-pay a bar's safe-area inset. */
639
+ bodyProps?: Record<string, unknown>;
640
+ /** Composes with the shell root's own `chorus-page-shell` class. Use for placement only; never to override the pin/scroll mechanics. */
641
+ className?: string;
642
+ }
643
+ export interface PageShellProps extends Omit<React.HTMLAttributes<HTMLDivElement>, keyof PageShellPropsOwn>, PageShellPropsOwn {}
644
+
613
645
  // ── Pagination (pagination/pagination) ──
614
646
  interface PaginationPropsOwn {
615
647
  /** Total number of pages — one dot renders per page. Below 2 the component renders nothing. */
@@ -631,7 +663,7 @@ interface ProfileHeaderPropsOwn {
631
663
  visibility?: "public" | "private";
632
664
  /** Override the visibility label text — used for localisation (e.g. '공개' / '비공개'). When omitted, defaults to 'Public' / 'Private' based on `visibility`. */
633
665
  visibilityLabel?: string;
634
- /** Follower count line — formatted by the consumer so the unit / locale stays in their hands (e.g. '999 followers', '12.4K followers', '999 팔로워'). Paints in `sys.typo.body.sm` / `sys.color.onSurfaceVariant` to the right of a bullet separator on the meta row. */
666
+ /** Follower count line — formatted by the consumer so the unit / locale stays in their hands (e.g. '999 followers', '12.4K followers', '999 팔로워'). Paints in `sys.typo.body.sm` / `sys.color.text.subtle` to the right of a bullet separator on the meta row. */
635
667
  followers: string;
636
668
  /** Whether the viewer is following this entity. Drives the trailing [Toggle Button](../button/toggle.md): false → 'Follow' (primary fill); true → 'Following' (transparent fill + hairline outline so the committed state recedes against any host surface tier). */
637
669
  followed?: boolean;
@@ -713,6 +745,17 @@ interface SkeletonPropsOwn {
713
745
  }
714
746
  export interface SkeletonProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, keyof SkeletonPropsOwn>, SkeletonPropsOwn {}
715
747
 
748
+ // ── Spinner (spinner/spinner) ──
749
+ interface SpinnerPropsOwn {
750
+ /** Selects the arc diameter off the `icon.*` ladder. `medium` paints at `sys.icon.lg` (24px); `small` at `sys.icon.md` (16px). */
751
+ size?: "medium" | "small";
752
+ /** Optional loading copy rendered beside the arc in `sys.typo.body.sm` / `sys.color.text.subtle`. When present it also supplies the accessible name, so `aria-label` is not required. */
753
+ label?: React.ReactNode;
754
+ /** Accessible label announced by screen readers. Defaults to `'Loading'`. Supply a more specific name (e.g. `'Signing in'`) when the wait scope is meaningful. Redundant when a visible `label` is passed. */
755
+ "aria-label"?: string;
756
+ }
757
+ export interface SpinnerProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, keyof SpinnerPropsOwn>, SpinnerPropsOwn {}
758
+
716
759
  // ── StatusTag (status-tag/status-tag) ──
717
760
  interface StatusTagPropsOwn {
718
761
  /** Tonal fill / foreground pair. `neutral` is the quiet informational default; `error` is the rejection / blocked / failed state. */
@@ -807,7 +850,7 @@ interface ThumbnailPropsOwn {
807
850
  updateDot?: boolean;
808
851
  /** { src, alt } — 16×16 circular badge at the bottom-right. */
809
852
  logoBadge?: Record<string, unknown>;
810
- /** When `true`, paints a 2-token-wide `sys.color.surface` outline around the Thumbnail container as an outset halo. The outline reads as an isolation ring that separates the Thumbnail's circular edge from anything visually noisy underneath it. Painted as `box-shadow: 0 0 0 sys.borderWidth.thin sys.color.surface` so it never reflows the slot's intrinsic diameter — same no-layout-stroke idiom the rest of the system uses. Pair with hosts whose chrome reads as a `surface*` tier (so the halo blends in) and whose backdrop differs from that tier (so the halo actually separates). */
853
+ /** When `true`, paints a 2-token-wide `sys.color.surface.default` outline around the Thumbnail container as an outset halo. The outline reads as an isolation ring that separates the Thumbnail's circular edge from anything visually noisy underneath it. Painted as `box-shadow: 0 0 0 sys.borderWidth.thin sys.color.surface.default` so it never reflows the slot's intrinsic diameter — same no-layout-stroke idiom the rest of the system uses. Pair with hosts whose chrome reads as a `surface*` tier (so the halo blends in) and whose backdrop differs from that tier (so the halo actually separates). */
811
854
  outlined?: boolean;
812
855
  }
813
856
  export interface ThumbnailProps extends Omit<React.HTMLAttributes<HTMLDivElement>, keyof ThumbnailPropsOwn>, ThumbnailPropsOwn {}
@@ -838,7 +881,6 @@ export interface TooltipProps extends Omit<React.HTMLAttributes<HTMLDivElement>,
838
881
  export interface TabProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
839
882
  export interface FeedGroupProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
840
883
  export interface FormFieldGroupProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
841
- export interface PageShellProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
842
884
  export interface NavCardGroupProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
843
885
  export interface CarouselProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
844
886
  export interface SideSheetGroupProps extends React.HTMLAttributes<HTMLElement> { children?: React.ReactNode; [key: string]: unknown; }
@@ -858,6 +900,7 @@ export const Chip: React.ForwardRefExoticComponent<ChipProps & React.RefAttribut
858
900
  export const Dialog: React.ForwardRefExoticComponent<DialogProps & React.RefAttributes<HTMLDivElement>>;
859
901
  export const Divider: React.ForwardRefExoticComponent<DividerProps & React.RefAttributes<HTMLElement>>;
860
902
  export const DirectoryList: React.ForwardRefExoticComponent<DirectoryListProps & React.RefAttributes<HTMLElement>>;
903
+ export const EmptyState: React.ForwardRefExoticComponent<EmptyStateProps & React.RefAttributes<HTMLDivElement>>;
861
904
  export const Feed: React.ForwardRefExoticComponent<FeedProps & React.RefAttributes<HTMLElement>>;
862
905
  export const FeedAd: React.ForwardRefExoticComponent<FeedAdProps & React.RefAttributes<HTMLElement>>;
863
906
  export const FormField: React.ForwardRefExoticComponent<FormFieldProps & React.RefAttributes<HTMLElement>>;
@@ -868,6 +911,7 @@ export const Metadata: React.ForwardRefExoticComponent<MetadataProps & React.Ref
868
911
  export const NavCard: React.ForwardRefExoticComponent<NavCardProps & React.RefAttributes<HTMLButtonElement>>;
869
912
  export const NavList: React.ForwardRefExoticComponent<NavListProps & React.RefAttributes<HTMLElement>>;
870
913
  export const NavigationBar: React.ForwardRefExoticComponent<NavigationBarProps & React.RefAttributes<HTMLElement>>;
914
+ export const PageShell: React.ForwardRefExoticComponent<PageShellProps & React.RefAttributes<HTMLDivElement>>;
871
915
  export const Pagination: React.ForwardRefExoticComponent<PaginationProps & React.RefAttributes<HTMLSpanElement>>;
872
916
  export const ProfileHeader: React.ForwardRefExoticComponent<ProfileHeaderProps & React.RefAttributes<HTMLElement>>;
873
917
  export const Progress: React.ForwardRefExoticComponent<ProgressProps & React.RefAttributes<HTMLDivElement>>;
@@ -875,6 +919,7 @@ export const PostCarousel: React.ForwardRefExoticComponent<PostCarouselProps & R
875
919
  export const ProfileCarousel: React.ForwardRefExoticComponent<ProfileCarouselProps & React.RefAttributes<HTMLDivElement>>;
876
920
  export const SideSheet: React.ForwardRefExoticComponent<SideSheetProps & React.RefAttributes<HTMLElement>>;
877
921
  export const Skeleton: React.ForwardRefExoticComponent<SkeletonProps & React.RefAttributes<HTMLSpanElement>>;
922
+ export const Spinner: React.ForwardRefExoticComponent<SpinnerProps & React.RefAttributes<HTMLSpanElement>>;
878
923
  export const StatusTag: React.ForwardRefExoticComponent<StatusTagProps & React.RefAttributes<HTMLSpanElement>>;
879
924
  export const Switch: React.ForwardRefExoticComponent<SwitchProps & React.RefAttributes<HTMLButtonElement>>;
880
925
  export const TabBar: React.ForwardRefExoticComponent<TabBarProps & React.RefAttributes<HTMLElement>>;
@@ -885,7 +930,6 @@ export const Tooltip: React.ForwardRefExoticComponent<TooltipProps & React.RefAt
885
930
  export const Tab: React.ForwardRefExoticComponent<TabProps & React.RefAttributes<HTMLElement>>;
886
931
  export const FeedGroup: React.ForwardRefExoticComponent<FeedGroupProps & React.RefAttributes<HTMLElement>>;
887
932
  export const FormFieldGroup: React.ForwardRefExoticComponent<FormFieldGroupProps & React.RefAttributes<HTMLElement>>;
888
- export const PageShell: React.ForwardRefExoticComponent<PageShellProps & React.RefAttributes<HTMLElement>>;
889
933
  export const NavCardGroup: React.ForwardRefExoticComponent<NavCardGroupProps & React.RefAttributes<HTMLElement>>;
890
934
  export const Carousel: React.ForwardRefExoticComponent<CarouselProps & React.RefAttributes<HTMLElement>>;
891
935
  export const SideSheetGroup: React.ForwardRefExoticComponent<SideSheetGroupProps & React.RefAttributes<HTMLElement>>;