wings-plugin-ui 0.3.0 → 0.4.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.4.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;
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
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
  }