wings-plugin-ui 0.3.0 → 0.5.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/markdown.js CHANGED
@@ -2,7 +2,10 @@
2
2
  // it, and the only URLs it lets through are https links and https or small data:image images.
3
3
  //
4
4
  // Blocks: headings, paragraphs, lists (flat, with an indent depth, and task items), fenced code, quotes, rules and
5
- // GFM tables. Inline: code, bold, italics, strikethrough, links, images and bare https URLs.
5
+ // GFM tables. Inline: code, bold, italics, strikethrough, links, images, bare https URLs and emoji shortcodes
6
+ // (`:white_check_mark:`), which stay literal inside code and when the name is unknown.
7
+
8
+ import { EMOJI } from "./emoji.js";
6
9
 
7
10
  /** A data: image longer than this, in characters, is shown as its alt text. */
8
11
  export const MAX_DATA_IMAGE = 8000;
@@ -142,7 +145,11 @@ export function parseBlocks(src) {
142
145
  // Every scan is bounded and stops at a newline, so a long run of brackets or markers costs a bounded step per start
143
146
  // rather than a pass over the rest of the text.
144
147
  const inlinePattern =
145
- /\[!\[([^\[\]\n]{0,200})\]\(([^)\s]{0,2000})\)\]\(([^)\s]{1,2000})\)|!\[([^\[\]\n]{0,200})\]\(([^)\s]{0,2000})\)|\[([^\[\]\n]{1,200})\]\(([^)\s]{1,2000})\)|`([^`\n]{1,2000})`|\*\*([^*\n]{1,2000})\*\*|~~([^~\n]{1,2000})~~|\*([^*\s][^*\n]{0,2000})\*|(https:\/\/[^\s<>"'`)\]]{1,2000})/gi;
148
+ /\[!\[([^\[\]\n]{0,200})\]\(([^)\s]{0,2000})\)\]\(([^)\s]{1,2000})\)|!\[([^\[\]\n]{0,200})\]\(([^)\s]{0,2000})\)|\[([^\[\]\n]{1,200})\]\(([^)\s]{1,2000})\)|`([^`\n]{1,2000})`|\*\*([^*\n]{1,2000})\*\*|~~([^~\n]{1,2000})~~|\*([^*\s][^*\n]{0,2000})\*|(https:\/\/[^\s<>"'`)\]]{1,2000})|:([a-z0-9_+-]{1,40}):/gi;
149
+
150
+ /** `text` with its known `:shortcodes:` as emoji, for spans that are plain text, like a bold or a link's text. */
151
+ export const emojify = (text) =>
152
+ text.includes(":") ? text.replace(/:([a-z0-9_+-]{1,40}):/gi, (all, name) => (Object.hasOwn(EMOJI, name.toLowerCase()) ? EMOJI[name.toLowerCase()] : all)) : text;
146
153
 
147
154
  const sentenceEnd = ".,;:!?";
148
155
 
@@ -172,7 +179,10 @@ export function parseInline(src) {
172
179
  else if (m[9] !== undefined) out.push({ kind: "strong", text: m[9] });
173
180
  else if (m[10] !== undefined) out.push({ kind: "del", text: m[10] });
174
181
  else if (m[11] !== undefined) out.push({ kind: "em", text: m[11] });
175
- else {
182
+ else if (m[13] !== undefined) {
183
+ const emoji = Object.hasOwn(EMOJI, m[13].toLowerCase()) ? EMOJI[m[13].toLowerCase()] : null;
184
+ out.push({ kind: "text", text: emoji ?? m[0] });
185
+ } else {
176
186
  // A bare URL, without the punctuation that ends a sentence.
177
187
  let end = m[12].length;
178
188
  while (end > 0 && sentenceEnd.includes(m[12][end - 1])) end--;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wings-plugin-ui",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -15,6 +15,7 @@
15
15
  "files": [
16
16
  "ui.js",
17
17
  "markdown.js",
18
+ "emoji.js",
18
19
  "ui.d.ts",
19
20
  "plugin-ui.css",
20
21
  "README.md",
package/plugin-ui.css CHANGED
@@ -744,6 +744,32 @@ textarea::placeholder {
744
744
  }
745
745
 
746
746
  /* Status: a dot, a spinner and a badge. */
747
+ .w-avatar {
748
+ display: inline-flex;
749
+ flex: none;
750
+ align-items: center;
751
+ justify-content: center;
752
+ width: 24px;
753
+ height: 24px;
754
+ border-radius: 50%;
755
+ background: hsl(var(--w-hue) 45% 32%);
756
+ color: hsl(var(--w-hue) 70% 90%);
757
+ font-size: 10px;
758
+ font-weight: 600;
759
+ line-height: 1;
760
+ letter-spacing: 0.02em;
761
+ user-select: none;
762
+ }
763
+ .w-avatar[data-size="small"] {
764
+ width: 18px;
765
+ height: 18px;
766
+ font-size: 8px;
767
+ }
768
+ .w-avatar[data-size="large"] {
769
+ width: 32px;
770
+ height: 32px;
771
+ font-size: 12px;
772
+ }
747
773
  .w-dot {
748
774
  flex: none;
749
775
  width: 6px;
@@ -1661,4 +1687,194 @@ span.w-md {
1661
1687
  .w-md-table [data-align="right"] {
1662
1688
  text-align: right;
1663
1689
  }
1690
+
1691
+ /* Activity: a summary row, skeletons and a timeline, shared by pages like a pull request and a work item. */
1692
+ .w-summary {
1693
+ display: flex;
1694
+ flex-wrap: wrap;
1695
+ align-items: center;
1696
+ gap: 6px;
1697
+ }
1698
+ .w-summary-chip {
1699
+ display: inline-flex;
1700
+ align-items: center;
1701
+ gap: 6px;
1702
+ max-width: 100%;
1703
+ height: 26px;
1704
+ padding: 0 10px;
1705
+ border: 1px solid var(--wings-hairline-strong);
1706
+ border-radius: 999px;
1707
+ background: var(--wings-surface);
1708
+ color: var(--wings-fg);
1709
+ font-size: 12px;
1710
+ font-weight: 500;
1711
+ white-space: nowrap;
1712
+ }
1713
+ button.w-summary-chip {
1714
+ cursor: pointer;
1715
+ transition: background-color 150ms;
1716
+ }
1717
+ button.w-summary-chip:hover {
1718
+ background: var(--wings-hover);
1719
+ }
1720
+ button.w-summary-chip:focus-visible {
1721
+ outline: 2px solid var(--wings-ring);
1722
+ outline-offset: 2px;
1723
+ }
1724
+ .w-summary-chip[data-tone="info"] {
1725
+ --w-tone: var(--wings-info);
1726
+ }
1727
+ .w-summary-chip[data-tone="success"] {
1728
+ --w-tone: var(--wings-success);
1729
+ }
1730
+ .w-summary-chip[data-tone="warn"] {
1731
+ --w-tone: var(--wings-warning);
1732
+ }
1733
+ .w-summary-chip[data-tone="danger"] {
1734
+ --w-tone: var(--wings-danger);
1735
+ }
1736
+ .w-summary-chip[data-tone] {
1737
+ border-color: color-mix(in srgb, var(--w-tone) 40%, transparent);
1738
+ color: var(--w-tone);
1739
+ }
1740
+
1741
+ .w-visually-hidden {
1742
+ position: absolute;
1743
+ width: 1px;
1744
+ height: 1px;
1745
+ overflow: hidden;
1746
+ clip-path: inset(50%);
1747
+ white-space: nowrap;
1748
+ }
1749
+ .w-skeletons {
1750
+ display: flex;
1751
+ flex-direction: column;
1752
+ gap: 12px;
1753
+ }
1754
+ .w-skeleton {
1755
+ display: block;
1756
+ width: var(--w-width, 100%);
1757
+ height: var(--w-height, 14px);
1758
+ border-radius: var(--wings-radius);
1759
+ background: linear-gradient(90deg, var(--wings-surface), var(--wings-hover), var(--wings-surface)) 0 0 / 200% 100%;
1760
+ animation: w-shimmer 1.4s ease-in-out infinite;
1761
+ }
1762
+ @keyframes w-shimmer {
1763
+ to {
1764
+ background-position: -200% 0;
1765
+ }
1766
+ }
1767
+ @media (prefers-reduced-motion: reduce) {
1768
+ .w-skeleton {
1769
+ animation: none;
1770
+ }
1771
+ }
1772
+
1773
+ .w-timeline {
1774
+ display: flex;
1775
+ flex-direction: column;
1776
+ gap: 10px;
1777
+ min-width: 0;
1778
+ margin: 0;
1779
+ padding: 0;
1780
+ list-style: none;
1781
+ }
1782
+ .w-timeline-item {
1783
+ min-width: 0;
1784
+ content-visibility: auto;
1785
+ contain-intrinsic-size: auto 96px;
1786
+ }
1787
+ .w-timeline-card {
1788
+ display: flex;
1789
+ flex-direction: column;
1790
+ gap: 10px;
1791
+ min-width: 0;
1792
+ padding: 12px 14px;
1793
+ border: 1px solid var(--wings-hairline);
1794
+ border-radius: var(--wings-radius);
1795
+ background: var(--wings-surface);
1796
+ }
1797
+ .w-timeline-item[data-quiet] > .w-timeline-card {
1798
+ background: transparent;
1799
+ font-size: 12px;
1800
+ }
1801
+ .w-timeline-head {
1802
+ display: flex;
1803
+ flex-wrap: wrap;
1804
+ align-items: center;
1805
+ gap: 4px 8px;
1806
+ min-width: 0;
1807
+ }
1808
+ .w-timeline-author {
1809
+ font-weight: 600;
1810
+ overflow-wrap: anywhere;
1811
+ }
1812
+ .w-timeline-tag {
1813
+ padding: 0 6px;
1814
+ border: 1px solid var(--wings-hairline-strong);
1815
+ border-radius: 999px;
1816
+ color: var(--wings-muted);
1817
+ font-size: 11px;
1818
+ line-height: 16px;
1819
+ }
1820
+ .w-timeline-time {
1821
+ color: var(--wings-muted);
1822
+ font-size: 12px;
1823
+ }
1824
+ .w-timeline-actions {
1825
+ display: inline-flex;
1826
+ align-items: center;
1827
+ gap: 6px;
1828
+ margin-left: auto;
1829
+ }
1830
+ .w-timeline-note {
1831
+ display: flex;
1832
+ align-items: center;
1833
+ gap: 8px;
1834
+ padding: 0 14px;
1835
+ color: var(--wings-muted);
1836
+ font-size: 12px;
1837
+ }
1838
+ .w-timeline-note-text {
1839
+ min-width: 0;
1840
+ overflow-wrap: anywhere;
1841
+ }
1842
+ .w-timeline-note-text strong {
1843
+ color: var(--wings-fg);
1844
+ font-weight: 600;
1845
+ }
1846
+ .w-clamp {
1847
+ display: flex;
1848
+ flex-direction: column;
1849
+ align-items: flex-start;
1850
+ gap: 4px;
1851
+ min-width: 0;
1852
+ }
1853
+ .w-clamp-box {
1854
+ width: 100%;
1855
+ min-width: 0;
1856
+ }
1857
+ .w-clamp-box[data-clamped] {
1858
+ max-height: 11em;
1859
+ overflow: hidden;
1860
+ }
1861
+ .w-clamp-box[data-cut] {
1862
+ mask-image: linear-gradient(to bottom, #000 70%, transparent);
1863
+ }
1864
+ .w-clamp-toggle {
1865
+ padding: 2px 6px;
1866
+ border: 0;
1867
+ border-radius: 6px;
1868
+ background: transparent;
1869
+ color: var(--wings-info);
1870
+ font-size: 12px;
1871
+ cursor: pointer;
1872
+ }
1873
+ .w-clamp-toggle:hover {
1874
+ background: var(--wings-hover);
1875
+ }
1876
+ .w-clamp-toggle:focus-visible {
1877
+ outline: 2px solid var(--wings-ring);
1878
+ outline-offset: 2px;
1879
+ }
1664
1880
  }
package/ui.d.ts CHANGED
@@ -174,6 +174,11 @@ export function Dot(
174
174
  label?: string;
175
175
  },
176
176
  ): ReactElement;
177
+ /**
178
+ * A round avatar with the initials of `name` (up to two letters) on a colour that depends only on the name, so a person
179
+ * looks the same everywhere. Decoration next to the name unless given a `label`, which names it for assistive tech.
180
+ */
181
+ export function Avatar(props: Props<"span"> & { name: string; size?: "small" | "large"; label?: string }): ReactElement;
177
182
  /** A spinner, 16px like the app's, in the colour of the text around it. With a `label` it's announced; otherwise it's decoration. */
178
183
  export function Spinner(props: Props<"span"> & { size?: "large"; label?: string }): ReactElement;
179
184
  /** shadcn/ui's Badge variants. */
@@ -339,7 +344,8 @@ export function Disclosure(
339
344
 
340
345
  /**
341
346
  * Markdown as React elements: headings, paragraphs, lists (with read-only task checkboxes), fenced code, quotes,
342
- * rules, GFM tables, bold, italics, strikethrough, inline code, links, bare https URLs and images. No HTML is ever
347
+ * rules, GFM tables, bold, italics, strikethrough, inline code, links, bare https URLs, images and emoji shortcodes like
348
+ * `:white_check_mark:` (unknown names and anything in code stay as typed). No HTML is ever
343
349
  * parsed. Links open through `wings.openUrl` and only when they are https, so the manifest needs
344
350
  * `permissions.openUrl: ["https://"]` for them to work. Images load only from https or `data:image/` (png, jpeg, gif,
345
351
  * webp) URLs of up to 8,000 characters; anything else shows its alt text. A remote https image shows as a "Load image from <host>" button and loads only when clicked, since text an agent wrote could otherwise leak data in the URL; a data: image shows at once. `inline` renders only the inline parts, in
@@ -348,3 +354,28 @@ export function Disclosure(
348
354
  export function Markdown(props: Omit<Props<"div">, "children"> & { source: string; inline?: boolean }): ReactElement;
349
355
  /** The longest `data:image/` URL, in characters, that `Markdown` shows. */
350
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
@@ -17,7 +17,7 @@ import { ToggleGroup as BaseToggleGroup } from "@base-ui/react/toggle-group";
17
17
  import { createContext, createElement as h, useContext, useEffect, useId, memo, useLayoutEffect, useRef, useState } from "react";
18
18
  import { createRoot } from "react-dom/client";
19
19
 
20
- import { MAX_DATA_IMAGE, parseBlocks, parseInline } from "./markdown.js";
20
+ import { MAX_DATA_IMAGE, emojify, parseBlocks, parseInline } from "./markdown.js";
21
21
 
22
22
  export { MAX_DATA_IMAGE };
23
23
 
@@ -365,6 +365,38 @@ export function Dot({ tone, color, label, className, style, ...rest }) {
365
365
  });
366
366
  }
367
367
 
368
+ /** Up to two initials of a name or login: "Ada Lovelace" is AL, "ada-bot[bot]" is AB, "ada" is A. */
369
+ const initialsOf = (name) => {
370
+ const words = name.replace(/\[bot\]$/i, "").split(/[^\p{L}\p{N}]+/u).filter(Boolean);
371
+ return (words.length > 1 ? words[0][0] + words[1][0] : (words[0] ?? "?").slice(0, 1)).toUpperCase();
372
+ };
373
+
374
+ /** A hue from 0 to 359 that depends only on the name, so a person has the same colour everywhere. */
375
+ const hueOf = (name) => {
376
+ let hash = 0;
377
+ for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
378
+ return hash % 360;
379
+ };
380
+
381
+ /**
382
+ * A round avatar with the initials of a `name` on a colour taken from it. Decoration next to the name by default;
383
+ * with a `label` it's announced.
384
+ */
385
+ export function Avatar({ name, size, label, className, style, ...rest }) {
386
+ return h(
387
+ "span",
388
+ {
389
+ ...rest,
390
+ className: cx("w-avatar", className),
391
+ // The hue comes from the name, as data: plugin-ui.css paints the avatar with it.
392
+ style: { ...style, "--w-hue": hueOf(name) },
393
+ "data-size": size,
394
+ ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }),
395
+ },
396
+ initialsOf(name),
397
+ );
398
+ }
399
+
368
400
  /** A spinner. With a `label` it's announced. */
369
401
  export function Spinner({ size, label, className, ...rest }) {
370
402
  return h("span", { ...rest, className: cx("w-spinner", className), "data-size": size, ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }) });
@@ -795,15 +827,15 @@ function MdInline({ text }) {
795
827
  case "code":
796
828
  return h("code", { key: i, className: "w-md-code" }, part.text);
797
829
  case "strong":
798
- return h("strong", { key: i }, part.text);
830
+ return h("strong", { key: i }, emojify(part.text));
799
831
  case "em":
800
- return h("em", { key: i }, part.text);
832
+ return h("em", { key: i }, emojify(part.text));
801
833
  case "del":
802
- return h("del", { key: i }, part.text);
834
+ return h("del", { key: i }, emojify(part.text));
803
835
  case "image":
804
836
  return h(MdImage, { key: i, src: part.src, alt: part.alt });
805
837
  case "link":
806
- return h("a", { key: i, className: "w-md-link", href: part.href, onClick: (e) => openLink(e, part.href) }, part.text);
838
+ return h("a", { key: i, className: "w-md-link", href: part.href, onClick: (e) => openLink(e, part.href) }, emojify(part.text));
807
839
  default:
808
840
  return part.text;
809
841
  }
@@ -871,3 +903,128 @@ export const Markdown = memo(function Markdown({ source, inline, className, ...r
871
903
  }),
872
904
  );
873
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
+ }