wings-plugin-ui 0.4.0 → 0.6.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.
package/README.md CHANGED
@@ -86,6 +86,10 @@ message.
86
86
  | `Combobox` | `label`, `value`, `onInput`, `options: { value, label, detail? }[]`, `onPick`, `onEnter`, `onKeyDown`, `freeText`, `openOnFocus`, `open`, `filter`, `empty` | A text field with suggestions under it: arrows, Enter, Escape and Tab, and a press outside closes it. With `freeText` it keeps what you type; otherwise it picks one of `options`. `openOnFocus={false}` for a page that puts focus back into it after a render. Without `freeText` the picked label is selected when the field gets focus and after a pick, so typing replaces it. `onEnter(text)` runs on Enter when no option is highlighted, with the typed text, and stops the Enter from submitting a form around it. An `onKeyDown` you pass runs first; `e.preventDefault()` in it skips the combobox's own keys. ArrowDown on a closed combobox opens it and highlights the first option, as in the [WAI-ARIA APG combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/). `open="whileText"` keeps the list shown for as long as the field has text and an option matches, even after Escape, Tab or a press outside (those only clear the highlighted option) and it closes when the text is emptied or an option is picked; `aria-expanded` follows whether the list is shown. Accessibility caveat: the APG pattern has Escape dismiss the popup and treats it as shown only while the user is working in the combobox, so this mode departs from it. Use it only where the suggestions are the page's main content, such as a people search that is useful to keep in view, and not for a field where a stray list would cover other controls |
87
87
  | `Menu` | `label`, `items: { id, label, onSelect, tone? }[]`, `align`, `variant`, `size` | A button that opens a menu: arrows, Home, End, Enter, Escape |
88
88
  | `Disclosure` | `summary` | More behind a line that opens it |
89
+ | `Time`, `ago` | `Time`: `iso`, `prefix`; `ago(iso)` gives the text | A moment as how long ago it was ("5 min ago", "3 h ago", "2 d ago", then the date) in a `time` element with the exact date and time on hover |
90
+ | `Summary`, `SummaryChip` | `SummaryChip`: `tone` (`info`, `success`, `warn`, `danger`), `onClick` | A row of small facts under a page's header, like "2 approved" or "Priority 1"; with `onClick` a chip is a button that goes to where the fact comes from |
91
+ | `Skeletons`, `Skeleton` | `Skeletons`: `label`; `Skeleton`: `width`, `height` | Blocks that stand in for loading content, announced once as `label` with `aria-busy`; they shimmer unless the user asked for less motion |
92
+ | `Timeline`, `TimelineCard`, `TimelineNote` | `Timeline`: `label`; `TimelineCard`: `author`, `at`, `tag`, `actions`, `quiet`, `clamp`; `TimelineNote`: `author`, `at` | A conversation or activity feed: a card per entry with the author's `Avatar`, name, time, an optional tag and actions, and the body as children (`Markdown` or your own); `quiet` for bots and the system; `clamp` cuts a long body with a "Show more" button that only appears when something was cut; a `TimelineNote` is a one-line entry like "Ada moved it to Active" |
89
93
  | `Markdown` | `source`, `inline` | Markdown as React: headings, lists with read-only task checkboxes, GFM tables, fenced code, quotes, rules, bold, italics, strikethrough, links and bare https URLs, images, and emoji shortcodes (`:white_check_mark:`, `:x:`, `:bulb:`: the GitHub and Slack names, flags left out; unknown names, and anything inside code, stay as typed). No HTML is ever parsed; only `https:` links open (through `wings.openUrl`, so the manifest needs `permissions.openUrl` with an `https://` prefix) and images load only from `https:` or `data:image/` png, jpeg, gif or webp URLs up to 8,000 characters (`MAX_DATA_IMAGE`); an `https:` image shows as a "Load image from <host>" button and loads only when clicked (no option to turn that off), because the text may come from an agent and the URL could carry data out, and a `data:` image shows at once; `[![alt](img)](url)` is a link with the alt as its text. `inline` renders just the inline parts in a span. Memoized on `source` |
90
94
 
91
95
  Every component passes other props, like `id`, `title`, `className`, `onClick` and `aria-*`, to its element.
@@ -119,6 +123,8 @@ page has:
119
123
  | `--wings-menu-bg`, `--wings-menu-ring`, `--wings-menu-active`, `--wings-shadow` | A raised layer like a menu or a list of suggestions, as Wings' own menus draw it: its opaque background, its edge, the highlighted option and its shadow |
120
124
  | `--wings-font`, `--wings-mono` | Fonts |
121
125
 
126
+ The colours, radius and font come from the Wings app's theme (`apps/desktop/src/index.css`), copied into the generated block at the top of `plugin-ui.css` by `pnpm tokens` in `apps/desktop`; edit the theme there, not the block. The file keeps those values, so the package works on its own.
127
+
122
128
  ```tsx
123
129
  // src/sidebar.tsx
124
130
  import "./sidebar.css";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wings-plugin-ui",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "React components and the stylesheet for Wings plugin pages, on Base UI like the Wings app's shadcn/ui components.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/plugin-ui.css CHANGED
@@ -9,26 +9,37 @@
9
9
  @layer wings-base, theme, base, wings-components, components, utilities;
10
10
 
11
11
  @layer wings-base {
12
+ /* tokens:start (generated from apps/desktop/src/index.css by apps/desktop/scripts/plugin-tokens.mjs, do not edit) */
12
13
  :root {
13
- color-scheme: dark;
14
- --wings-bg: transparent;
15
- --wings-surface: rgb(255 255 255 / 0.04);
16
14
  --wings-fg: #ece9e4;
17
15
  --wings-muted: #9d978f;
18
16
  --wings-hover: rgb(255 255 255 / 0.06);
19
- --wings-pressed: rgb(255 255 255 / 0.1);
17
+ --wings-pressed: rgb(255 255 255 / 0.10);
20
18
  --wings-hairline: rgb(255 255 255 / 0.07);
21
19
  --wings-hairline-strong: rgb(255 255 255 / 0.16);
22
- --wings-input: rgb(255 255 255 / 0.06);
23
20
  --wings-ring: rgb(255 255 255 / 0.32);
24
21
  --wings-primary: #ece9e4;
25
22
  --wings-primary-fg: #17151a;
26
- --wings-info: #9cc0ff;
27
23
  --wings-success: #7dd3a7;
28
24
  --wings-warning: #e9b65f;
29
25
  --wings-danger: #f2786a;
30
- --wings-radius: 10px;
26
+ --wings-secondary: rgb(255 255 255 / 0.08);
27
+ --wings-border: rgb(255 255 255 / 0.08);
28
+ --wings-control-border: rgb(255 255 255 / 0.10);
29
+ --wings-menu-bg: rgb(32 29 35);
30
+ --wings-menu-active: rgb(255 255 255 / 0.08);
31
+ --wings-accent-fg: #f4f1ec;
32
+ --wings-radius: 0.625rem;
31
33
  --wings-font: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, Ubuntu, sans-serif;
34
+ }
35
+ /* tokens:end */
36
+
37
+ :root {
38
+ color-scheme: dark;
39
+ --wings-bg: transparent;
40
+ --wings-surface: rgb(255 255 255 / 0.04);
41
+ --wings-input: rgb(255 255 255 / 0.06);
42
+ --wings-info: #9cc0ff;
32
43
  --wings-mono: ui-monospace, "SF Mono", Menlo, monospace;
33
44
  }
34
45
 
@@ -164,16 +175,12 @@ textarea::placeholder {
164
175
  /* Raised layers, like a list of suggestions or a menu, as Wings' own menus draw them, and the controls as the app's
165
176
  own shadcn/ui ones do. */
166
177
  :root {
167
- --wings-menu-bg: rgb(32 29 35);
168
- --wings-menu-ring: rgb(236 233 228 / 0.1);
169
- --wings-menu-active: rgb(255 255 255 / 0.08);
178
+ --wings-menu-ring: color-mix(in srgb, var(--wings-fg) 10%, transparent);
179
+ --wings-shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
170
180
  --wings-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
171
- --wings-border: rgb(255 255 255 / 0.08);
172
- --wings-control-border: rgb(255 255 255 / 0.1);
173
- --wings-control-bg: rgb(255 255 255 / 0.03);
174
- --wings-control-bg-hover: rgb(255 255 255 / 0.05);
175
- --wings-secondary: rgb(255 255 255 / 0.08);
176
- --wings-ring-soft: rgb(255 255 255 / 0.16);
181
+ --wings-control-bg: color-mix(in srgb, var(--wings-control-border) 30%, transparent);
182
+ --wings-control-bg-hover: color-mix(in srgb, var(--wings-control-border) 50%, transparent);
183
+ --wings-ring-soft: color-mix(in srgb, var(--wings-ring) 50%, transparent);
177
184
  --wings-radius-sm: calc(var(--wings-radius) * 0.6);
178
185
  --wings-radius-md: calc(var(--wings-radius) * 0.8);
179
186
  }
@@ -947,50 +954,50 @@ textarea::placeholder {
947
954
  transform 100ms;
948
955
  }
949
956
  .w-button[data-variant="default"]:hover {
950
- background: color-mix(in srgb, var(--wings-primary) 80%, transparent);
957
+ background-color: color-mix(in srgb, var(--wings-primary) 80%, transparent);
951
958
  }
952
959
  .w-button[data-variant="secondary"] {
953
- background: var(--wings-secondary);
960
+ background-color: var(--wings-secondary);
954
961
  color: var(--wings-fg);
955
962
  }
956
963
  .w-button[data-variant="secondary"]:hover {
957
- background: color-mix(in srgb, var(--wings-secondary), var(--wings-fg) 5%);
964
+ background-color: color-mix(in srgb, var(--wings-secondary), var(--wings-fg) 5%);
958
965
  }
959
966
  .w-button[data-variant="outline"] {
960
967
  border-color: var(--wings-control-border);
961
- background: var(--wings-control-bg);
968
+ background-color: var(--wings-control-bg);
962
969
  color: var(--wings-fg);
963
970
  }
964
971
  .w-button[data-variant="outline"]:hover {
965
- background: var(--wings-control-bg-hover);
972
+ background-color: var(--wings-control-bg-hover);
966
973
  }
967
974
  .w-button[data-variant="outline"][aria-expanded="true"] {
968
- background: var(--wings-hover);
975
+ background-color: var(--wings-hover);
969
976
  }
970
977
  .w-button[data-variant="ghost"] {
971
- background: transparent;
978
+ background-color: transparent;
972
979
  color: var(--wings-fg);
973
980
  }
974
981
  .w-button[data-variant="ghost"]:hover {
975
- background: color-mix(in srgb, var(--wings-hover) 50%, transparent);
982
+ background-color: color-mix(in srgb, var(--wings-hover) 50%, transparent);
976
983
  }
977
984
  .w-button[data-variant="ghost"][aria-expanded="true"] {
978
- background: var(--wings-hover);
985
+ background-color: var(--wings-hover);
979
986
  }
980
987
  .w-button[data-variant="destructive"] {
981
- background: color-mix(in srgb, var(--wings-danger) 20%, transparent);
988
+ background-color: color-mix(in srgb, var(--wings-danger) 20%, transparent);
982
989
  color: var(--wings-danger);
983
990
  }
984
991
  .w-button[data-variant="destructive"]:hover {
985
- background: color-mix(in srgb, var(--wings-danger) 30%, transparent);
992
+ background-color: color-mix(in srgb, var(--wings-danger) 30%, transparent);
986
993
  }
987
994
  .w-button[data-variant="link"] {
988
- background: transparent;
995
+ background-color: transparent;
989
996
  color: var(--wings-primary);
990
997
  text-underline-offset: 4px;
991
998
  }
992
999
  .w-button[data-variant="link"]:hover {
993
- background: transparent;
1000
+ background-color: transparent;
994
1001
  text-decoration: underline;
995
1002
  }
996
1003
  .w-button[data-size="xs"] {
@@ -999,6 +1006,7 @@ textarea::placeholder {
999
1006
  padding: 0 8px;
1000
1007
  border-radius: var(--wings-radius-md);
1001
1008
  font-size: 12px;
1009
+ line-height: 16px;
1002
1010
  }
1003
1011
  .w-button[data-size="sm"] {
1004
1012
  gap: 4px;
@@ -1006,6 +1014,7 @@ textarea::placeholder {
1006
1014
  padding: 0 10px;
1007
1015
  border-radius: var(--wings-radius-md);
1008
1016
  font-size: 12.8px;
1017
+ line-height: 1.5;
1009
1018
  }
1010
1019
  .w-button[data-size="lg"] {
1011
1020
  height: 36px;
@@ -1073,12 +1082,13 @@ textarea::placeholder {
1073
1082
  min-width: 28px;
1074
1083
  height: 28px;
1075
1084
  padding: 0 10px;
1076
- border: 1px solid transparent;
1085
+ border: 0;
1077
1086
  border-radius: var(--wings-radius-md);
1078
1087
  background: transparent;
1079
1088
  color: var(--wings-fg);
1080
1089
  font-size: 12.8px;
1081
1090
  font-weight: 500;
1091
+ line-height: 1.5;
1082
1092
  white-space: nowrap;
1083
1093
  cursor: pointer;
1084
1094
  outline: none;
@@ -1160,7 +1170,7 @@ textarea::placeholder {
1160
1170
  border-color: var(--wings-control-border);
1161
1171
  background: var(--wings-control-bg);
1162
1172
  color: var(--wings-fg);
1163
- box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
1173
+ box-shadow: var(--wings-shadow-sm);
1164
1174
  }
1165
1175
  .w-tabs[data-variant="line"] > .w-tab[data-active] {
1166
1176
  border-color: transparent;
@@ -1263,7 +1273,7 @@ textarea::placeholder {
1263
1273
  .w-select {
1264
1274
  justify-content: space-between;
1265
1275
  gap: 6px;
1266
- padding: 0 8px 0 10px;
1276
+ padding: 8px 8px 8px 10px;
1267
1277
  text-align: left;
1268
1278
  white-space: nowrap;
1269
1279
  user-select: none;
@@ -1377,6 +1387,20 @@ textarea::placeholder {
1377
1387
  border-color: color-mix(in srgb, var(--wings-danger) 40%, transparent);
1378
1388
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--wings-danger) 40%, transparent);
1379
1389
  }
1390
+ /* The app's outline button and active tab keep their border on focus, since their dark border colour outranks the
1391
+ focus one there. */
1392
+ .w-button[data-variant="outline"]:focus-visible,
1393
+ .w-tab[data-active]:focus-visible {
1394
+ border-color: var(--wings-control-border);
1395
+ }
1396
+ .w-tab[data-active]:focus-visible {
1397
+ box-shadow:
1398
+ 0 0 0 3px var(--wings-ring-soft),
1399
+ var(--wings-shadow-sm);
1400
+ }
1401
+ .w-tabs[data-variant="line"] > .w-tab[data-active]:focus-visible {
1402
+ box-shadow: 0 0 0 3px var(--wings-ring-soft);
1403
+ }
1380
1404
  .w-tab:focus-visible {
1381
1405
  outline: 1px solid var(--wings-ring);
1382
1406
  }
@@ -1459,7 +1483,7 @@ textarea::placeholder {
1459
1483
  .w-option[data-highlighted],
1460
1484
  .w-menu-item[data-highlighted] {
1461
1485
  background: var(--wings-menu-active);
1462
- color: #f4f1ec;
1486
+ color: var(--wings-accent-fg);
1463
1487
  }
1464
1488
  .w-select-item[data-disabled],
1465
1489
  .w-menu-item[data-disabled] {
@@ -1687,4 +1711,194 @@ span.w-md {
1687
1711
  .w-md-table [data-align="right"] {
1688
1712
  text-align: right;
1689
1713
  }
1714
+
1715
+ /* Activity: a summary row, skeletons and a timeline, shared by pages like a pull request and a work item. */
1716
+ .w-summary {
1717
+ display: flex;
1718
+ flex-wrap: wrap;
1719
+ align-items: center;
1720
+ gap: 6px;
1721
+ }
1722
+ .w-summary-chip {
1723
+ display: inline-flex;
1724
+ align-items: center;
1725
+ gap: 6px;
1726
+ max-width: 100%;
1727
+ height: 26px;
1728
+ padding: 0 10px;
1729
+ border: 1px solid var(--wings-hairline-strong);
1730
+ border-radius: 999px;
1731
+ background: var(--wings-surface);
1732
+ color: var(--wings-fg);
1733
+ font-size: 12px;
1734
+ font-weight: 500;
1735
+ white-space: nowrap;
1736
+ }
1737
+ button.w-summary-chip {
1738
+ cursor: pointer;
1739
+ transition: background-color 150ms;
1740
+ }
1741
+ button.w-summary-chip:hover {
1742
+ background: var(--wings-hover);
1743
+ }
1744
+ button.w-summary-chip:focus-visible {
1745
+ outline: 2px solid var(--wings-ring);
1746
+ outline-offset: 2px;
1747
+ }
1748
+ .w-summary-chip[data-tone="info"] {
1749
+ --w-tone: var(--wings-info);
1750
+ }
1751
+ .w-summary-chip[data-tone="success"] {
1752
+ --w-tone: var(--wings-success);
1753
+ }
1754
+ .w-summary-chip[data-tone="warn"] {
1755
+ --w-tone: var(--wings-warning);
1756
+ }
1757
+ .w-summary-chip[data-tone="danger"] {
1758
+ --w-tone: var(--wings-danger);
1759
+ }
1760
+ .w-summary-chip[data-tone] {
1761
+ border-color: color-mix(in srgb, var(--w-tone) 40%, transparent);
1762
+ color: var(--w-tone);
1763
+ }
1764
+
1765
+ .w-visually-hidden {
1766
+ position: absolute;
1767
+ width: 1px;
1768
+ height: 1px;
1769
+ overflow: hidden;
1770
+ clip-path: inset(50%);
1771
+ white-space: nowrap;
1772
+ }
1773
+ .w-skeletons {
1774
+ display: flex;
1775
+ flex-direction: column;
1776
+ gap: 12px;
1777
+ }
1778
+ .w-skeleton {
1779
+ display: block;
1780
+ width: var(--w-width, 100%);
1781
+ height: var(--w-height, 14px);
1782
+ border-radius: var(--wings-radius);
1783
+ background: linear-gradient(90deg, var(--wings-surface), var(--wings-hover), var(--wings-surface)) 0 0 / 200% 100%;
1784
+ animation: w-shimmer 1.4s ease-in-out infinite;
1785
+ }
1786
+ @keyframes w-shimmer {
1787
+ to {
1788
+ background-position: -200% 0;
1789
+ }
1790
+ }
1791
+ @media (prefers-reduced-motion: reduce) {
1792
+ .w-skeleton {
1793
+ animation: none;
1794
+ }
1795
+ }
1796
+
1797
+ .w-timeline {
1798
+ display: flex;
1799
+ flex-direction: column;
1800
+ gap: 10px;
1801
+ min-width: 0;
1802
+ margin: 0;
1803
+ padding: 0;
1804
+ list-style: none;
1805
+ }
1806
+ .w-timeline-item {
1807
+ min-width: 0;
1808
+ content-visibility: auto;
1809
+ contain-intrinsic-size: auto 96px;
1810
+ }
1811
+ .w-timeline-card {
1812
+ display: flex;
1813
+ flex-direction: column;
1814
+ gap: 10px;
1815
+ min-width: 0;
1816
+ padding: 12px 14px;
1817
+ border: 1px solid var(--wings-hairline);
1818
+ border-radius: var(--wings-radius);
1819
+ background: var(--wings-surface);
1820
+ }
1821
+ .w-timeline-item[data-quiet] > .w-timeline-card {
1822
+ background: transparent;
1823
+ font-size: 12px;
1824
+ }
1825
+ .w-timeline-head {
1826
+ display: flex;
1827
+ flex-wrap: wrap;
1828
+ align-items: center;
1829
+ gap: 4px 8px;
1830
+ min-width: 0;
1831
+ }
1832
+ .w-timeline-author {
1833
+ font-weight: 600;
1834
+ overflow-wrap: anywhere;
1835
+ }
1836
+ .w-timeline-tag {
1837
+ padding: 0 6px;
1838
+ border: 1px solid var(--wings-hairline-strong);
1839
+ border-radius: 999px;
1840
+ color: var(--wings-muted);
1841
+ font-size: 11px;
1842
+ line-height: 16px;
1843
+ }
1844
+ .w-timeline-time {
1845
+ color: var(--wings-muted);
1846
+ font-size: 12px;
1847
+ }
1848
+ .w-timeline-actions {
1849
+ display: inline-flex;
1850
+ align-items: center;
1851
+ gap: 6px;
1852
+ margin-left: auto;
1853
+ }
1854
+ .w-timeline-note {
1855
+ display: flex;
1856
+ align-items: center;
1857
+ gap: 8px;
1858
+ padding: 0 14px;
1859
+ color: var(--wings-muted);
1860
+ font-size: 12px;
1861
+ }
1862
+ .w-timeline-note-text {
1863
+ min-width: 0;
1864
+ overflow-wrap: anywhere;
1865
+ }
1866
+ .w-timeline-note-text strong {
1867
+ color: var(--wings-fg);
1868
+ font-weight: 600;
1869
+ }
1870
+ .w-clamp {
1871
+ display: flex;
1872
+ flex-direction: column;
1873
+ align-items: flex-start;
1874
+ gap: 4px;
1875
+ min-width: 0;
1876
+ }
1877
+ .w-clamp-box {
1878
+ width: 100%;
1879
+ min-width: 0;
1880
+ }
1881
+ .w-clamp-box[data-clamped] {
1882
+ max-height: 11em;
1883
+ overflow: hidden;
1884
+ }
1885
+ .w-clamp-box[data-cut] {
1886
+ mask-image: linear-gradient(to bottom, #000 70%, transparent);
1887
+ }
1888
+ .w-clamp-toggle {
1889
+ padding: 2px 6px;
1890
+ border: 0;
1891
+ border-radius: 6px;
1892
+ background: transparent;
1893
+ color: var(--wings-info);
1894
+ font-size: 12px;
1895
+ cursor: pointer;
1896
+ }
1897
+ .w-clamp-toggle:hover {
1898
+ background: var(--wings-hover);
1899
+ }
1900
+ .w-clamp-toggle:focus-visible {
1901
+ outline: 2px solid var(--wings-ring);
1902
+ outline-offset: 2px;
1903
+ }
1690
1904
  }
package/ui.d.ts CHANGED
@@ -354,3 +354,28 @@ export function Disclosure(
354
354
  export function Markdown(props: Omit<Props<"div">, "children"> & { source: string; inline?: boolean }): ReactElement;
355
355
  /** The longest `data:image/` URL, in characters, that `Markdown` shows. */
356
356
  export const MAX_DATA_IMAGE: number;
357
+
358
+ // ---------- activity: times, a summary row, skeletons and a timeline ----------
359
+
360
+ /** "just now", "5 min ago", "3 h ago", "2 d ago", then the date; "" when `iso` isn't a date. `now` is for tests. */
361
+ export function ago(iso: string, now?: number): string;
362
+ /** A moment as a `time` element: how long ago (after `prefix`), the exact date and time as its title. Renders nothing when `iso` isn't a date. */
363
+ export function Time(props: Omit<Props<"time">, "dateTime" | "children"> & { iso: string; prefix?: ReactNode; now?: number }): ReactElement | null;
364
+ /** A row of `SummaryChip`s under a page's header. */
365
+ export function Summary(props: Props<"div">): ReactElement;
366
+ /** One fact in a `Summary`: a button when it has `onClick`, text otherwise. `tone` colours it. */
367
+ export function SummaryChip(props: Omit<Props<"span">, "onClick"> & { tone?: "info" | "success" | "warn" | "danger"; onClick?: Props<"button">["onClick"]; type?: "button" | "submit" }): ReactElement;
368
+ /** A block that stands in for loading content, `width` and `height` in px or any CSS length. Decoration: wrap a group in `Skeletons`. */
369
+ export function Skeleton(props: Props<"span"> & { width?: number | string; height?: number | string }): ReactElement;
370
+ /** A group of `Skeleton`s announced once as `label`, with `aria-busy`. */
371
+ export function Skeletons(props: Omit<Props<"div">, "role" | "aria-label"> & { label: string }): ReactElement;
372
+ /** An ordered list of `TimelineCard`s and `TimelineNote`s, named `label`. */
373
+ export function Timeline(props: Props<"ol"> & { label: string }): ReactElement;
374
+ /**
375
+ * An entry of a `Timeline`: the `Avatar`, name and time of `author` and `at` (ISO), an optional `tag` and `actions`
376
+ * at the right of that line, and `children` as the body. `quiet` for what a bot or the system wrote; `clamp` cuts a long
377
+ * body until "Show more".
378
+ */
379
+ export function TimelineCard(props: Omit<Props<"li">, "children"> & { author: string; at?: string; tag?: ReactNode; actions?: ReactNode; quiet?: boolean; clamp?: boolean; children?: ReactNode }): ReactElement;
380
+ /** A quiet one-line entry of a `Timeline`: a small avatar, the author in bold, `children`, then the time. */
381
+ export function TimelineNote(props: Omit<Props<"li">, "children"> & { author: string; at?: string; children?: ReactNode }): ReactElement;
package/ui.js CHANGED
@@ -903,3 +903,128 @@ export const Markdown = memo(function Markdown({ source, inline, className, ...r
903
903
  }),
904
904
  );
905
905
  });
906
+
907
+ // ---------- activity: times, a summary row, skeletons and a timeline ----------
908
+
909
+ /** "just now", "5 min ago", "3 h ago", "2 d ago", then the date. "" for something that isn't a date. */
910
+ export function ago(iso, now = Date.now()) {
911
+ const t = Date.parse(iso);
912
+ if (Number.isNaN(t)) return "";
913
+ const minutes = Math.max(0, Math.round((now - t) / 60_000));
914
+ if (minutes < 1) return "just now";
915
+ if (minutes < 60) return `${minutes} min ago`;
916
+ if (minutes < 1440) return `${Math.round(minutes / 60)} h ago`;
917
+ if (minutes < 43_200) return `${Math.round(minutes / 1440)} d ago`;
918
+ return new Date(t).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
919
+ }
920
+
921
+ /** A moment as a `time` element: how long ago, with the exact date and time on hover. Nothing when `iso` isn't a date. */
922
+ export function Time({ iso, prefix, now, className, ...rest }) {
923
+ const text = ago(iso, now);
924
+ if (!text) return null;
925
+ return h("time", { title: new Date(iso).toLocaleString(), ...rest, className, dateTime: iso }, prefix, text);
926
+ }
927
+
928
+ /** A row of `SummaryChip`s under a page's header: the facts about the thing at a glance. */
929
+ export function Summary({ className, ...rest }) {
930
+ return h("div", { ...rest, className: cx("w-summary", className) });
931
+ }
932
+
933
+ /** One fact in a `Summary`. With `onClick` it's a button (it takes you to where the fact comes from); without, plain text. */
934
+ export function SummaryChip({ tone, className, onClick, type = "button", ...rest }) {
935
+ const props = { ...rest, className: cx("w-summary-chip", className), "data-tone": tone };
936
+ return onClick ? h("button", { ...props, type, onClick }) : h("span", props);
937
+ }
938
+
939
+ /** A grey block that stands in for content that is still loading, sized by `width` and `height` (CSS lengths or px). It shimmers unless the user asked for less motion. */
940
+ export function Skeleton({ width, height, className, style, ...rest }) {
941
+ const size = (v) => (typeof v === "number" ? `${v}px` : v);
942
+ return h("span", {
943
+ ...rest,
944
+ className: cx("w-skeleton", className),
945
+ style: { ...style, ...(width === undefined ? null : { "--w-width": size(width) }), ...(height === undefined ? null : { "--w-height": size(height) }) },
946
+ "aria-hidden": true,
947
+ });
948
+ }
949
+
950
+ /** Skeletons that stand in for a section, announced once as `label` and `aria-busy`. */
951
+ export function Skeletons({ label, children, className, ...rest }) {
952
+ // The label is text inside the region, which a live region announces; an aria-label alone isn't.
953
+ return h("div", { ...rest, className: cx("w-skeletons", className), role: "status", "aria-busy": true }, h("span", { className: "w-visually-hidden" }, label), children);
954
+ }
955
+
956
+ /** A conversation or an activity feed: an ordered list of `TimelineCard`s and `TimelineNote`s. */
957
+ export function Timeline({ label, className, ...rest }) {
958
+ return h("ol", { ...rest, className: cx("w-timeline", className), "aria-label": label });
959
+ }
960
+
961
+ const FOCUSABLE = "a[href], button, input, select, textarea, summary, [tabindex]";
962
+
963
+ /** Cuts `children` after a few lines until the reader asks for the rest; focusable things below the cut are inert meanwhile. The button only shows when something was cut. */
964
+ function Clamped({ children }) {
965
+ const box = useRef(null);
966
+ const [more, setMore] = useState(false);
967
+ const [cut, setCut] = useState(false);
968
+ useLayoutEffect(() => {
969
+ const el = box.current;
970
+ if (!el) return;
971
+ if (more) {
972
+ for (const f of el.querySelectorAll(FOCUSABLE)) f.inert = false;
973
+ return;
974
+ }
975
+ const measure = () => {
976
+ setCut(el.scrollHeight > el.clientHeight + 1);
977
+ // What the cut hides can't be reached by keyboard or screen reader until "Show more".
978
+ const bottom = el.getBoundingClientRect().bottom;
979
+ for (const f of el.querySelectorAll(FOCUSABLE)) f.inert = f.getBoundingClientRect().top >= bottom - 1;
980
+ };
981
+ measure();
982
+ if (typeof ResizeObserver === "undefined") return;
983
+ const watch = new ResizeObserver(measure);
984
+ watch.observe(el);
985
+ return () => watch.disconnect();
986
+ }, [more]);
987
+ return h(
988
+ "div",
989
+ { className: "w-clamp" },
990
+ h("div", { ref: box, className: "w-clamp-box", "data-clamped": more ? undefined : "", "data-cut": cut && !more ? "" : undefined }, children),
991
+ cut || more ? h("button", { type: "button", className: "w-clamp-toggle", "aria-expanded": more, onClick: () => setMore(!more) }, more ? "Show less" : "Show more") : null,
992
+ );
993
+ }
994
+
995
+ /**
996
+ * One entry of a `Timeline`, a card: the author's `Avatar`, name and when, a `tag` (like "Author") and `actions` on the
997
+ * right, and `children` as the body. `quiet` draws it without a fill, for what a bot or the system wrote. `clamp` cuts
998
+ * a long body until "Show more" is pressed.
999
+ */
1000
+ export function TimelineCard({ author, at, tag, actions, quiet, clamp, children, className, ...rest }) {
1001
+ return h(
1002
+ "li",
1003
+ { ...rest, className: cx("w-timeline-item", className), "data-quiet": quiet ? "" : undefined },
1004
+ h(
1005
+ "article",
1006
+ { className: "w-timeline-card", "aria-label": `By ${author}` },
1007
+ h(
1008
+ "div",
1009
+ { className: "w-timeline-head" },
1010
+ h(Avatar, { name: author }),
1011
+ h("strong", { className: "w-timeline-author" }, author),
1012
+ tag ? h("span", { className: "w-timeline-tag" }, tag) : null,
1013
+ h(Time, { iso: at, className: "w-timeline-time" }),
1014
+ actions ? h("span", { className: "w-timeline-actions" }, actions) : null,
1015
+ ),
1016
+ clamp ? h(Clamped, null, children) : children,
1017
+ ),
1018
+ );
1019
+ }
1020
+
1021
+ /** A quiet one-line entry of a `Timeline`, like "Ada moved it to Active": a small avatar, then `children`, then when. */
1022
+ export function TimelineNote({ author, at, children, className, ...rest }) {
1023
+ return h(
1024
+ "li",
1025
+ { ...rest, className: cx("w-timeline-item w-timeline-note", className) },
1026
+ h(Avatar, { name: author, size: "small" }),
1027
+ h("span", { className: "w-timeline-note-text" }, h("strong", null, author), " ", children),
1028
+ h(Time, { iso: at, className: "w-timeline-time" }),
1029
+ );
1030
+ }