@gemboss/ui 0.41.0 → 0.42.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.
@@ -37,9 +37,9 @@ export {
37
37
  } from "./panel";
38
38
  export {
39
39
  ChipButton, ColorControl, EdgeBox, ImageControl, RangeControl, Seg, UnitField, ValueControl,
40
- type ColorControlProps, type EdgeBoxProps, type EdgeSide, type ImageControlProps, type RangeControlProps, type SegOption, type SegProps, type UnitFieldProps, type ValueControlProps,
40
+ type ColorChoice, type ColorControlProps, type EdgeBoxProps, type EdgeSide, type ImageControlProps, type RangeControlProps, type SegOption, type SegProps, type UnitFieldProps, type ValueControlProps,
41
41
  } from "./controls";
42
- export { swatchState, swatchWhy, type SwatchState } from "./swatch";
42
+ export { opacityOf, parseColor, pickSwatch, swatchState, swatchWhy, themeColorChoices, withOpacity, type Rgba, type SwatchState } from "./swatch";
43
43
  export { cssUrl } from "./css-url";
44
44
  // Step 4 (0.22.0): the page's layers and the drawer (`layers`, stateless; the page editor draws them), a list whose rows
45
45
  // reorder (`sortable`, "use client": a drag holds state; no admin screen uses it), and the element outline (`tree`, the
@@ -4,7 +4,7 @@
4
4
  // `<input type="color">` holds exactly one shape: a 7-character hex. Anything else it SANITISES to
5
5
  // `#000000` — silently, with no error and no visual difference from a value someone actually chose.
6
6
  //
7
- // gemsite's `cssValue` decides whether a background paints, and it accepts five shapes. Measured:
7
+ // gemsite's `cssValue` paints any safe string, so a field holds every shape CSS has. Measured:
8
8
  //
9
9
  // #ff0000 ✔ renders ✔ the swatch can show it
10
10
  // #f00 ✔ renders ✘ shown as #000000 — even a 3-digit hex
@@ -19,11 +19,15 @@
19
19
  // None of this is hypothetical. `gem_background` renders a gradient today, measured in chromium, and
20
20
  // every one of these shapes is something the CLONE pipeline can read off a real storefront.
21
21
  //
22
+ // THE SWATCH READS EVERY COLOUR, NOT ONLY HEX (A4, 21/10/2026). Measured on 169 element pages of gemboss-dev: of 3,283 colour
23
+ // values, 66% are `rgb()`, 17% `rgba()`, 9% a 6-digit hex, ~3% `oklch()` (cloned pages carry what the source's CSS computed), 0.3%
24
+ // a theme token. Only the hex 9% had a swatch: nine colours in ten showed a locked black square. A colour is now read (parseColor:
25
+ // hex of 3, 4, 6 or 8 digits, rgb/rgba, hsl/hsla, oklch) and the swatch shows it; a pick keeps the value's transparency and its way
26
+ // of writing (pickSwatch), so turning the swatch on can never drop an alpha. A token, a gradient or a scheme id is still not a colour
27
+ // the swatch can stand for: off, and saying why.
28
+ //
22
29
  // Plain TypeScript, no JSX: the admin's node tests import it through the admin's re-export.
23
30
 
24
- /** The only shape `<input type="color">` can hold without rewriting it. */
25
- const SWATCHABLE = /^#[0-9a-fA-F]{6}$/;
26
-
27
31
  export type SwatchState = {
28
32
  /** What to put in the swatch. Never a colour the author did not choose. */
29
33
  swatch: string;
@@ -31,6 +35,95 @@ export type SwatchState = {
31
35
  usable: boolean;
32
36
  };
33
37
 
38
+ /** A colour as the swatch and the opacity box hold it: 0-255 channels and an alpha of 0-1. */
39
+ export type Rgba = { r: number; g: number; b: number; a: number };
40
+
41
+ const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
42
+ const NUM = "[+-]?(?:\\d+\\.?\\d*|\\.\\d+)(?:e[+-]?\\d+)?";
43
+ /** A number, a percentage (of `pct`), or `none` (0): one CSS colour argument. */
44
+ function arg(s: string, pct: number): number | null {
45
+ if (s === "none") return 0;
46
+ const m = new RegExp(`^(${NUM})(%?)$`, "i").exec(s);
47
+ if (!m) return null;
48
+ const n = Number(m[1]);
49
+ return m[2] ? (n / 100) * pct : n;
50
+ }
51
+ /** `f(a b c / d)`, `f(a, b, c)` or `f(a, b, c, d)`: the arguments, or null for anything else. */
52
+ function args(v: string, fn: RegExp): string[] | null {
53
+ const m = new RegExp(`^(?:${fn.source})\\(\\s*([^()]*?)\\s*\\)$`, "i").exec(v);
54
+ if (!m) return null;
55
+ const body = m[1]!;
56
+ const [main, slash, ...more] = body.split("/");
57
+ if (more.length) return null;
58
+ const parts = main!.includes(",") ? main!.split(",").map((s) => s.trim()) : main!.trim().split(/\s+/);
59
+ if (slash !== undefined) parts.push(slash.trim());
60
+ return parts.every(Boolean) ? parts : null;
61
+ }
62
+ const alphaOf = (s: string | undefined): number | null => (s === undefined ? 1 : arg(s, 1));
63
+ const hue = (s: string): number | null => arg(s.replace(/deg$/i, ""), 360);
64
+
65
+ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
66
+ const k = (n: number) => (n + h / 30) % 12;
67
+ const a = s * Math.min(l, 1 - l);
68
+ const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
69
+ return [f(0) * 255, f(8) * 255, f(4) * 255];
70
+ }
71
+ function oklchToRgb(L: number, C: number, H: number): [number, number, number] {
72
+ const a = C * Math.cos((H * Math.PI) / 180), b = C * Math.sin((H * Math.PI) / 180);
73
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
74
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
75
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
76
+ const lin = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
77
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
78
+ const gamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
79
+ return lin.map((c) => clamp(gamma(c), 0, 1) * 255) as [number, number, number];
80
+ }
81
+
82
+ /** The colour a value names, or null for anything that is not one colour (a token, a gradient, a scheme id, a padded value). */
83
+ export function parseColor(value: string): Rgba | null {
84
+ if (!value || value !== value.trim()) return null;
85
+ const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value)?.[1];
86
+ if (hex) {
87
+ const full = hex.length <= 4 ? hex.split("").map((c) => c + c).join("") : hex;
88
+ const n = (i: number) => parseInt(full.slice(i, i + 2), 16);
89
+ return { r: n(0), g: n(2), b: n(4), a: full.length === 8 ? Math.round((n(6) / 255) * 1000) / 1000 : 1 };
90
+ }
91
+ const rgb = args(value, /rgba?/);
92
+ if (rgb && (rgb.length === 3 || rgb.length === 4)) {
93
+ const [r, g, b] = rgb.slice(0, 3).map((s) => arg(s, 255));
94
+ const a = alphaOf(rgb[3]);
95
+ if (r == null || g == null || b == null || a == null) return null;
96
+ return { r: clamp(Math.round(r), 0, 255), g: clamp(Math.round(g), 0, 255), b: clamp(Math.round(b), 0, 255), a: clamp(a, 0, 1) };
97
+ }
98
+ const hsl = args(value, /hsla?/);
99
+ if (hsl && (hsl.length === 3 || hsl.length === 4)) {
100
+ const h = hue(hsl[0]!), s = arg(hsl[1]!, 1), l = arg(hsl[2]!, 1), a = alphaOf(hsl[3]);
101
+ // `hsl(0 50 50)` (no %) reads the numbers as percentages, as CSS Color 4 does.
102
+ const pc = (x: number, raw: string) => (raw.endsWith("%") ? x : x / 100);
103
+ if (h == null || s == null || l == null || a == null) return null;
104
+ const [r, g, b] = hslToRgb(((h % 360) + 360) % 360, clamp(pc(s, hsl[1]!), 0, 1), clamp(pc(l, hsl[2]!), 0, 1));
105
+ return { r: Math.round(r), g: Math.round(g), b: Math.round(b), a: clamp(a, 0, 1) };
106
+ }
107
+ const ok = args(value, /oklch/);
108
+ if (ok && (ok.length === 3 || ok.length === 4)) {
109
+ const L = arg(ok[0]!, 1), C = arg(ok[1]!, 0.4), H = hue(ok[2]!), a = alphaOf(ok[3]);
110
+ if (L == null || C == null || H == null || a == null) return null;
111
+ const [r, g, b] = oklchToRgb(L, C, H);
112
+ return { r: Math.round(r), g: Math.round(g), b: Math.round(b), a: clamp(a, 0, 1) };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ const hex2 = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0");
118
+ const hexOf = (c: Pick<Rgba, "r" | "g" | "b">) => `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}`;
119
+ const alphaText = (a: number) => String(Math.round(a * 100) / 100);
120
+
121
+ /** A colour written as the field already writes it: a hex stays a hex, anything else becomes `rgb()`; with transparency `rgba()`. */
122
+ function writeLike(previous: string, c: Rgba): string {
123
+ if (c.a >= 1) return /^#/.test(previous) || !previous ? hexOf(c) : `rgb(${c.r}, ${c.g}, ${c.b})`;
124
+ return `rgba(${c.r}, ${c.g}, ${c.b}, ${alphaText(c.a)})`;
125
+ }
126
+
34
127
  /**
35
128
  * `whenEmpty` is the swatch shown for a BLANK value, and it is a parameter rather than a constant on
36
129
  * purpose. Three surfaces call this now and they disagree: the element inspector and the schema form
@@ -41,7 +134,29 @@ export type SwatchState = {
41
134
  */
42
135
  export function swatchState(value: string, whenEmpty = '#000000'): SwatchState {
43
136
  if (!value) return { swatch: whenEmpty, usable: true };
44
- return SWATCHABLE.test(value) ? { swatch: value, usable: true } : { swatch: '#000000', usable: false };
137
+ const c = parseColor(value);
138
+ return c ? { swatch: hexOf(c), usable: true } : { swatch: '#000000', usable: false };
139
+ }
140
+
141
+ /** The value after `hex` is picked on its swatch: the value's transparency and its way of writing kept (a pick never drops an alpha). */
142
+ export function pickSwatch(value: string, hex: string): string {
143
+ const picked = parseColor(hex);
144
+ if (!picked) return value;
145
+ const now = parseColor(value);
146
+ return writeLike(value, { ...picked, a: now ? now.a : 1 });
147
+ }
148
+
149
+ /** A value's opacity in whole percent (100 for an opaque colour), or null when it is not one colour. */
150
+ export function opacityOf(value: string): number | null {
151
+ const c = parseColor(value);
152
+ return c ? Math.round(c.a * 100) : null;
153
+ }
154
+
155
+ /** The value with its opacity set to `percent` (0-100), written as the field writes it; unchanged when it is not one colour. */
156
+ export function withOpacity(value: string, percent: number): string {
157
+ const c = parseColor(value);
158
+ if (!c || !Number.isFinite(percent)) return value;
159
+ return writeLike(value, { ...c, a: clamp(Math.round(percent), 0, 100) / 100 });
45
160
  }
46
161
 
47
162
  /** The theme's colour tokens a value may name (`var(--gem-color-accent)`), as a seller reads them. */
@@ -49,17 +164,26 @@ const TOKEN_NAMES: Record<string, string> = { text: "Text", accent: "Accent", "a
49
164
  surface: "Surface", border: "Border", success: "Success", warning: "Warning", danger: "Danger" };
50
165
  const tokenName = (t: string) => TOKEN_NAMES[t] ?? t.charAt(0).toUpperCase() + t.slice(1).replace(/-/g, " ");
51
166
 
167
+ /** The theme's colours as ColorControl chips (A4b), in the theme's own order and names, for the roles `palette` has: each writes
168
+ * `var(--gem-color-<role>)` and paints the colour the page draws for it (a frame's palette, gemsite InspectorBridge). A role the
169
+ * palette does not say, or says as something that is not one colour, has no chip. */
170
+ export function themeColorChoices(palette: Readonly<Record<string, string>> | null | undefined): Array<{ value: string; label: string; swatch: string }> {
171
+ if (!palette) return [];
172
+ return Object.keys(TOKEN_NAMES).filter((t) => palette[t] && parseColor(palette[t]!.trim()))
173
+ .map((t) => ({ value: `var(--gem-color-${t})`, label: TOKEN_NAMES[t]!, swatch: palette[t]!.trim() }));
174
+ }
175
+
52
176
  /**
53
177
  * WHY THE SWATCH IS OFF, said where a seller sees it (Claude Design: a disabled control says why). The editors' selection toolbar
54
178
  * writes the theme's own colours as tokens (`var(--gem-color-accent)`), so every colour picked there turned its field's swatch grey,
55
179
  * with only a tooltip a finger or the keyboard never reaches (inventory of 19/10/2026). Null when the swatch shows the value.
56
180
  */
57
181
  export function swatchWhy(value: string): string | null {
58
- // Exactly when the swatch is off (swatchState's one rule): a padded hex is off too, and says so (review of #4636).
182
+ // Exactly when the swatch is off (swatchState's one rule): a padded value is off too, and says so (review of #4636).
59
183
  if (swatchState(value).usable) return null;
60
184
  const v = value.trim();
61
185
  const token = /^var\(\s*--gem-color-([a-z-]+)\s*\)$/i.exec(v)?.[1]?.toLowerCase();
62
- if (token) return `Theme color: ${tokenName(token)}. Type a hex to use your own.`;
63
- if (/^var\(/i.test(v)) return "A theme value. Type a hex to use your own.";
64
- return "Kept as written: the swatch picks 6-digit hex colors only.";
186
+ if (token) return `Theme color: ${tokenName(token)}. Type a color to use your own.`;
187
+ if (/^var\(/i.test(v)) return "A theme value. Type a color to use your own.";
188
+ return "Kept as written: the swatch shows one color (hex, rgb, hsl or oklch), not a gradient or a name.";
65
189
  }
@@ -1 +1 @@
1
- (function(E){"use strict";var v=E.gembossUI;if(!v){E.console&&E.console.error("@gemboss/ui editor-plain.js: load kit-plain.js first (it defines gembossUI)");return}if(v.initEditorFrame)return;var k=5,R=360,p=typeof WeakSet=="function"?new WeakSet:null,u=function(t,e,r){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:r}))},S=function(t,e){var r=Number(t);return isFinite(r)&&r>0?r:e};function c(t){return t.querySelector(".gemboss-editor-body")}function g(t){var e=c(t);return e&&e.querySelector(":scope > .gemboss-editor-resizer")}function b(t){var e=g(t);return{min:S(e&&e.getAttribute("aria-valuemin"),300),cap:S(e&&(e.getAttribute("data-gb-max")||e.getAttribute("aria-valuemax")),680)}}function x(t){var e=b(t),r=c(t),i=r?r.getBoundingClientRect().width:0;return i>0?Math.max(e.min,Math.min(e.cap,i-R-k)):e.cap}var h=typeof WeakMap=="function"?new WeakMap:null;function C(t){var e=h?h.get(t):t._gbWant;return e??y(t)}function q(t,e){h?h.set(t,e):t._gbWant=e}function d(t,e,r,i){var n=c(t),a=g(t);if(n){var s=i??x(t),f=b(t),o=Math.round(Math.min(s,Math.max(f.min,e)));if(n.style.setProperty("--gemboss-chat-w",o+"px"),a&&(a.setAttribute("aria-valuenow",String(o)),a.setAttribute("aria-valuemax",String(s))),r){q(t,o);var L=n.getAttribute("data-gb-storage");if(L)try{localStorage.setItem(L,String(o))}catch{}u(t,"gemboss:editor-split",{width:o})}return o}}function y(t){var e=g(t);return S(e&&e.getAttribute("aria-valuenow"),384)}function z(t){var e=c(t),r=g(t);if(!(!e||!r)){r.hasAttribute("data-gb-max")||r.setAttribute("data-gb-max",r.getAttribute("aria-valuemax")||"680");var i=e.getAttribute("data-gb-storage"),n=y(t);if(i)try{var a=Number(localStorage.getItem(i));a>=b(t).min&&a<=b(t).cap&&(n=a)}catch{}q(t,n),e.classList.contains("is-chat-collapsed")||d(t,n,!1)}}function O(t,e){var r=c(t);if(r&&!(e.button!==0||e.isPrimary===!1)){e.preventDefault();var i=r.getBoundingClientRect().left,n=x(t),a=0,s=y(t);document.body.classList.add("ui-resizing");var f=function(L){s=L.clientX-i-k,a||(a=requestAnimationFrame(function(){a=0,d(t,s,!1,n)}))},o=function(){a&&cancelAnimationFrame(a),document.body.classList.remove("ui-resizing"),removeEventListener("pointermove",f),removeEventListener("pointerup",o),removeEventListener("pointercancel",o),removeEventListener("blur",o),d(t,s,!0,n)};addEventListener("pointermove",f),addEventListener("pointerup",o),addEventListener("pointercancel",o),addEventListener("blur",o)}}function D(t,e){if(!(e.altKey||e.metaKey||e.ctrlKey)){var r=e.shiftKey?64:16,i=y(t),n=b(t);if(e.key==="ArrowLeft")i-=r;else if(e.key==="ArrowRight")i+=r;else if(e.key==="Home")i=n.min;else if(e.key==="End")i=x(t);else return;e.preventDefault(),d(t,i,!0)}}function F(t,e){var r=c(t);if(r){var i=!r.classList.contains("is-chat-collapsed");r.classList.toggle("is-chat-collapsed",i),i?r.style.setProperty("--gemboss-chat-w","0px"):d(t,C(t),!1),e.classList.toggle("collapsed",i),e.setAttribute("aria-pressed",String(i)),e.setAttribute("aria-label",i?"Show chat panel":"Hide chat panel"),e.title=i?"Show chat panel":"Hide chat \u2014 full-width preview";var n=e.querySelector("rect[fill='currentColor']");n&&(n.style.display=i?"none":""),u(t,"gemboss:editor-chat",{collapsed:i})}}function K(t,e){var r=e.closest(".gemboss-pv-devices");r&&(r.querySelectorAll(".gemboss-pv-device").forEach(function(i){var n=i===e;i.classList.toggle("active",n),i.setAttribute("aria-pressed",String(n))}),u(t,"gemboss:editor-device",{device:e.getAttribute("data-device")||(e.title||"").toLowerCase()}))}function U(t,e){var r=e.getAttribute("data-pane")||(Array.prototype.indexOf.call(e.parentNode.children,e)===0?"chat":"preview"),i=c(t);i&&(i.setAttribute("data-mobile-pane",r),e.parentNode.querySelectorAll(".gemboss-editor-mobile-tab").forEach(function(n){var a=n===e;n.classList.toggle("active",a),n.setAttribute("aria-selected",String(a))}),u(t,"gemboss:editor-pane",{pane:r}))}var l=null;function M(t){var e=t.trigger.getBoundingClientRect();t.menu.style.top=e.bottom+6+"px",t.menu.style.right=Math.max(8,innerWidth-e.right)+"px";var r=t.menu.getBoundingClientRect(),i=e.right-r.right;r.left+i<8&&(i=8-r.left),Math.abs(i)>=.5&&(t.menu.style.right=parseFloat(t.menu.style.right)-i+"px")}function m(t){var e=l;e&&(l=null,e.menu.hidden=!0,e.trigger.setAttribute("aria-expanded","false"),e.trigger.classList.remove("active"),t&&e.trigger.focus(),u(e.root,"gemboss:editor-menu",{open:!1,menu:e.menu}))}function H(t,e){var r=e.nextElementSibling;if(!r||!r.classList.contains("gemboss-editor-menu"))return!1;var i=l&&l.trigger===e;return m(!1),i||(l={root:t,trigger:e,menu:r},r.hidden=!1,e.setAttribute("aria-expanded","true"),e.classList.add("active"),M(l),u(t,"gemboss:editor-menu",{open:!0,menu:r})),!0}var N=!1;function $(){N||(N=!0,document.addEventListener("pointerdown",function(t){l&&!l.menu.contains(t.target)&&!l.trigger.contains(t.target)&&m(!1)}),addEventListener("keydown",function(t){t.key!=="Escape"||!l||t.isComposing||t.keyCode===229||(t.preventDefault(),t.stopImmediatePropagation(),m(!0))},!0),addEventListener("resize",function(){l&&M(l)}),addEventListener("blur",function(){m(!1)}))}function j(t){var e=t&&t.classList&&t.classList.contains("gemboss-editor")?[t]:Array.prototype.slice.call((t||document).querySelectorAll(".gemboss-editor"));return $(),e.forEach(function(r){if(!(p?p.has(r):r._gbEditor)){p?p.add(r):r._gbEditor=!0,z(r);var i=c(r);i&&typeof ResizeObserver=="function"&&new ResizeObserver(function(){i.classList.contains("is-chat-collapsed")||d(r,C(r),!1)}).observe(i),r.addEventListener("pointerdown",function(n){var a=n.target.closest(".gemboss-editor-resizer");a&&a===g(r)&&O(r,n)}),r.addEventListener("keydown",function(n){n.target.classList&&n.target.classList.contains("gemboss-editor-resizer")&&n.target===g(r)&&D(r,n)}),r.addEventListener("click",function(n){var a=n.target,s;if(s=a.closest(".gemboss-editor-chat-toggle"))return F(r,s);if(s=a.closest(".gemboss-pv-devices .gemboss-pv-device"))return K(r,s);if(s=a.closest(".gemboss-editor-mobile-tab"))return U(r,s);if((s=a.closest('[aria-haspopup="menu"]'))&&H(r,s)){n.stopPropagation();return}l&&(s=a.closest(".gemboss-editor-menu-item"))&&l.menu.contains(s)&&m(!0)})}}),e.length}v.initEditorFrame=j;var G=/^#[0-9a-fA-F]{6}$/,P=function(t){var e=t.getAttribute("data-value");return e??(t.textContent||"").trim()},W=function(t){return Array.prototype.filter.call(t.children,function(e){return e.getAttribute("role")==="tab"})};function I(t,e){var r=t.parentNode,i=W(r),n=i.some(function(a){return a.hasAttribute("tabindex")});i.forEach(function(a){var s=a===t;a.setAttribute("aria-selected",String(s)),a.classList.contains("gemboss-ei-tab")&&a.classList.toggle("on",s),n&&a.setAttribute("tabindex",s?"0":"-1");var f=a.getAttribute("aria-controls"),o=f&&document.getElementById(f);o&&(o.hidden=!s)}),e&&t.focus(),u(t,"gemboss:editor-tab",{tab:P(t),index:i.indexOf(t)})}function V(t,e){if(!(e.key!=="ArrowRight"&&e.key!=="ArrowLeft")){var r=W(t);if(r.some(function(a){return a.hasAttribute("tabindex")})){var i=r.findIndex(function(a){return a.getAttribute("aria-selected")==="true"}),n=e.key==="ArrowRight"?(i+1)%r.length:(i-1+r.length)%r.length;e.preventDefault(),I(r[n],!0)}}}function X(t){var e=t.getAttribute("data-open")!=="open";t.setAttribute("data-open",e?"open":"closed"),t.setAttribute("aria-expanded",String(e));var r=t.querySelector(".gemboss-ei-chev");r&&(r.textContent=e?"\u25BE":"\u25B8"),u(t,"gemboss:editor-group",{open:e})}function J(t){var e=t.closest(".gemboss-ei-seg");Array.prototype.forEach.call(e.querySelectorAll("button"),function(r){var i=r===t;r.classList.toggle("active",i),r.classList.toggle("on",i),r.hasAttribute("aria-pressed")&&r.setAttribute("aria-pressed",String(i))}),u(e,"gemboss:editor-seg",{value:P(t)})}var Q={text:"Text",accent:"Accent","accent-text":"Accent text",muted:"Muted",background:"Background",surface:"Surface",border:"Border",success:"Success",warning:"Warning",danger:"Danger"},Y=0;function Z(t){var e=t.trim(),r=/^var\(\s*--gem-color-([a-z-]+)\s*\)$/i.exec(e);if(r){var i=r[1].toLowerCase();return"Theme color: "+(Q[i]||i.charAt(0).toUpperCase()+i.slice(1).replace(/-/g," "))+". Type a hex to use your own."}return/^var\(/i.test(e)?"A theme value. Type a hex to use your own.":"Kept as written: the swatch picks 6-digit hex colors only."}function B(t){var e=t.querySelector(".gemboss-ei-swatch"),r=t.querySelector("input:not([type=color])");if(!(!e||!r)){var i=r.value,n=!i||G.test(i);e.value=i&&n?i:"#000000",e.disabled=r.disabled||!n;var a=t.querySelector(".gemboss-ei-color-why");if(n){e.removeAttribute("title"),e.removeAttribute("aria-describedby"),a&&a.parentNode.removeChild(a);return}var s=Z(i);e.title=s,a||(a=document.createElement("p"),a.className="gemboss-ei-color-why",t.appendChild(a)),a.id||(a.id="gemboss-ei-color-why-"+ ++Y),a.textContent=s,e.setAttribute("aria-describedby",a.id)}}function ee(t){for(var e=t.labels||[],r=null,i=0;i<e.length&&!r;i++)r=e[i].querySelector(".gemboss-ei-rangeval");r&&(r.textContent=t.value+(r.getAttribute("data-suffix")||""))}function te(t){var e={},r=["t","r","b","l"];return Array.prototype.forEach.call(t.querySelectorAll("input"),function(i,n){e[r[n]]=i.value.trim()}),e}function T(t){return'url("'+t.replace(/["\\]/g,"\\$&").replace(/[\u0000-\u001f\u007f]/g,function(e){return"\\"+e.charCodeAt(0).toString(16)+" "})+'")'}v.cssUrl=T;function re(t,e){var r=t.querySelector(".gemboss-ei-img-thumb");r&&(r.style.backgroundImage=e?T(e):"",r.textContent=e?"":"\u2014")}function _(t,e){var r=t.getAttribute("data-gb-value")!=null?t.getAttribute("data-gb-value"):e.defaultValue;e.value!==r&&(t.setAttribute("data-gb-value",e.value),re(t,e.value),u(t,"gemboss:editor-image",{value:e.value}))}var A=typeof WeakSet=="function"?new WeakSet:null,w=function(t){return t.__gbPanel?!1:(t.__gbPanel=!0,!0)};function ie(t){var e=t||document;return(A?A.has(e):e._gbPanel)?0:(A?A.add(e):e._gbPanel=!0,Array.prototype.forEach.call(e.querySelectorAll(".gemboss-ei-color"),B),e.addEventListener("click",function(r){var i=r.target,n;if(!(!i.closest||!w(r))){if(n=i.closest('.gemboss-ec-tabs > [role="tab"], .gemboss-ei-tabs > [role="tab"]'))return I(n,!1);if(n=i.closest(".gemboss-ei-gh"))return X(n);if((n=i.closest(".gemboss-ei-seg button"))&&!n.disabled)return J(n);if((n=i.closest("[data-gb-image-clear]"))&&!n.disabled){var a=n.closest(".gemboss-ei-img"),s=a&&a.querySelector(".gemboss-ei-img-ctl input");s&&(s.value="",_(a,s))}}}),e.addEventListener("keydown",function(r){var i=r.target;if(!(!i.closest||!w(r))){var n=i.closest('.gemboss-ec-tabs[role="tablist"], .gemboss-ei-tabs[role="tablist"]');if(n&&i.getAttribute("role")==="tab")return V(n,r);r.key==="Enter"&&i.tagName==="INPUT"&&i.type!=="color"&&i.type!=="range"&&i.closest(".gemboss-ei-color, .gemboss-ei-edgebox, .gemboss-ei-img")&&(r.preventDefault(),i.blur())}}),e.addEventListener("input",function(r){var i=r.target;if(!(!i.closest||!w(r))){if(i.classList.contains("gemboss-ei-range")){ee(i),u(i,"gemboss:editor-range",{value:Number(i.value)});return}if(i.classList.contains("gemboss-ei-swatch")){var n=i.closest(".gemboss-ei-color"),a=n&&n.querySelector("input:not([type=color])");a&&(a.value=i.value),u(n||i,"gemboss:editor-color",{value:i.value})}}}),e.addEventListener("focusout",function(r){var i=r.target;if(!(!i.closest||i.tagName!=="INPUT"||!w(r))){var n;if(i.type!=="color"&&(n=i.closest(".gemboss-ei-color"))){B(n),u(n,"gemboss:editor-color",{value:i.value});return}if(n=i.closest(".gemboss-ei-edgebox")){u(n,"gemboss:editor-edges",te(n));return}(n=i.closest(".gemboss-ei-img"))&&_(n,i)}}),1)}v.initEditorPanel=ie})(typeof globalThis<"u"?globalThis:this);
1
+ (function(k){"use strict";var g=k.gembossUI;if(!g){k.console&&k.console.error("@gemboss/ui editor-plain.js: load kit-plain.js first (it defines gembossUI)");return}if(g.initEditorFrame)return;var N=5,K=360,A=typeof WeakSet=="function"?new WeakSet:null,u=function(t,e,r){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:r}))},M=function(t,e){var r=Number(t);return isFinite(r)&&r>0?r:e};function d(t){return t.querySelector(".gemboss-editor-body")}function b(t){var e=d(t);return e&&e.querySelector(":scope > .gemboss-editor-resizer")}function p(t){var e=b(t);return{min:M(e&&e.getAttribute("aria-valuemin"),300),cap:M(e&&(e.getAttribute("data-gb-max")||e.getAttribute("aria-valuemax")),680)}}function C(t){var e=p(t),r=d(t),i=r?r.getBoundingClientRect().width:0;return i>0?Math.max(e.min,Math.min(e.cap,i-K-N)):e.cap}var w=typeof WeakMap=="function"?new WeakMap:null;function q(t){var e=w?w.get(t):t._gbWant;return e??S(t)}function O(t,e){w?w.set(t,e):t._gbWant=e}function m(t,e,r,i){var a=d(t),n=b(t);if(a){var s=i??C(t),o=p(t),l=Math.round(Math.min(s,Math.max(o.min,e)));if(a.style.setProperty("--gemboss-chat-w",l+"px"),n&&(n.setAttribute("aria-valuenow",String(l)),n.setAttribute("aria-valuemax",String(s))),r){O(t,l);var f=a.getAttribute("data-gb-storage");if(f)try{localStorage.setItem(f,String(l))}catch{}u(t,"gemboss:editor-split",{width:l})}return l}}function S(t){var e=b(t);return M(e&&e.getAttribute("aria-valuenow"),384)}function U(t){var e=d(t),r=b(t);if(!(!e||!r)){r.hasAttribute("data-gb-max")||r.setAttribute("data-gb-max",r.getAttribute("aria-valuemax")||"680");var i=e.getAttribute("data-gb-storage"),a=S(t);if(i)try{var n=Number(localStorage.getItem(i));n>=p(t).min&&n<=p(t).cap&&(a=n)}catch{}O(t,a),e.classList.contains("is-chat-collapsed")||m(t,a,!1)}}function j(t,e){var r=d(t);if(r&&!(e.button!==0||e.isPrimary===!1)){e.preventDefault();var i=r.getBoundingClientRect().left,a=C(t),n=0,s=S(t);document.body.classList.add("ui-resizing");var o=function(f){s=f.clientX-i-N,n||(n=requestAnimationFrame(function(){n=0,m(t,s,!1,a)}))},l=function(){n&&cancelAnimationFrame(n),document.body.classList.remove("ui-resizing"),removeEventListener("pointermove",o),removeEventListener("pointerup",l),removeEventListener("pointercancel",l),removeEventListener("blur",l),m(t,s,!0,a)};addEventListener("pointermove",o),addEventListener("pointerup",l),addEventListener("pointercancel",l),addEventListener("blur",l)}}function H(t,e){if(!(e.altKey||e.metaKey||e.ctrlKey)){var r=e.shiftKey?64:16,i=S(t),a=p(t);if(e.key==="ArrowLeft")i-=r;else if(e.key==="ArrowRight")i+=r;else if(e.key==="Home")i=a.min;else if(e.key==="End")i=C(t);else return;e.preventDefault(),m(t,i,!0)}}function G(t,e){var r=d(t);if(r){var i=!r.classList.contains("is-chat-collapsed");r.classList.toggle("is-chat-collapsed",i),i?r.style.setProperty("--gemboss-chat-w","0px"):m(t,q(t),!1),e.classList.toggle("collapsed",i),e.setAttribute("aria-pressed",String(i)),e.setAttribute("aria-label",i?"Show chat panel":"Hide chat panel"),e.title=i?"Show chat panel":"Hide chat \u2014 full-width preview";var a=e.querySelector("rect[fill='currentColor']");a&&(a.style.display=i?"none":""),u(t,"gemboss:editor-chat",{collapsed:i})}}function V(t,e){var r=e.closest(".gemboss-pv-devices");r&&(r.querySelectorAll(".gemboss-pv-device").forEach(function(i){var a=i===e;i.classList.toggle("active",a),i.setAttribute("aria-pressed",String(a))}),u(t,"gemboss:editor-device",{device:e.getAttribute("data-device")||(e.title||"").toLowerCase()}))}function X(t,e){var r=e.getAttribute("data-pane")||(Array.prototype.indexOf.call(e.parentNode.children,e)===0?"chat":"preview"),i=d(t);i&&(i.setAttribute("data-mobile-pane",r),e.parentNode.querySelectorAll(".gemboss-editor-mobile-tab").forEach(function(a){var n=a===e;a.classList.toggle("active",n),a.setAttribute("aria-selected",String(n))}),u(t,"gemboss:editor-pane",{pane:r}))}var c=null;function I(t){var e=t.trigger.getBoundingClientRect();t.menu.style.top=e.bottom+6+"px",t.menu.style.right=Math.max(8,innerWidth-e.right)+"px";var r=t.menu.getBoundingClientRect(),i=e.right-r.right;r.left+i<8&&(i=8-r.left),Math.abs(i)>=.5&&(t.menu.style.right=parseFloat(t.menu.style.right)-i+"px")}function h(t){var e=c;e&&(c=null,e.menu.hidden=!0,e.trigger.setAttribute("aria-expanded","false"),e.trigger.classList.remove("active"),t&&e.trigger.focus(),u(e.root,"gemboss:editor-menu",{open:!1,menu:e.menu}))}function J(t,e){var r=e.nextElementSibling;if(!r||!r.classList.contains("gemboss-editor-menu"))return!1;var i=c&&c.trigger===e;return h(!1),i||(c={root:t,trigger:e,menu:r},r.hidden=!1,e.setAttribute("aria-expanded","true"),e.classList.add("active"),I(c),u(t,"gemboss:editor-menu",{open:!0,menu:r})),!0}var P=!1;function Q(){P||(P=!0,document.addEventListener("pointerdown",function(t){c&&!c.menu.contains(t.target)&&!c.trigger.contains(t.target)&&h(!1)}),addEventListener("keydown",function(t){t.key!=="Escape"||!c||t.isComposing||t.keyCode===229||(t.preventDefault(),t.stopImmediatePropagation(),h(!0))},!0),addEventListener("resize",function(){c&&I(c)}),addEventListener("blur",function(){h(!1)}))}function Y(t){var e=t&&t.classList&&t.classList.contains("gemboss-editor")?[t]:Array.prototype.slice.call((t||document).querySelectorAll(".gemboss-editor"));return Q(),e.forEach(function(r){if(!(A?A.has(r):r._gbEditor)){A?A.add(r):r._gbEditor=!0,U(r);var i=d(r);i&&typeof ResizeObserver=="function"&&new ResizeObserver(function(){i.classList.contains("is-chat-collapsed")||m(r,q(r),!1)}).observe(i),r.addEventListener("pointerdown",function(a){var n=a.target.closest(".gemboss-editor-resizer");n&&n===b(r)&&j(r,a)}),r.addEventListener("keydown",function(a){a.target.classList&&a.target.classList.contains("gemboss-editor-resizer")&&a.target===b(r)&&H(r,a)}),r.addEventListener("click",function(a){var n=a.target,s;if(s=n.closest(".gemboss-editor-chat-toggle"))return G(r,s);if(s=n.closest(".gemboss-pv-devices .gemboss-pv-device"))return V(r,s);if(s=n.closest(".gemboss-editor-mobile-tab"))return X(r,s);if((s=n.closest('[aria-haspopup="menu"]'))&&J(r,s)){a.stopPropagation();return}c&&(s=n.closest(".gemboss-editor-menu-item"))&&c.menu.contains(s)&&h(!0)})}}),e.length}g.initEditorFrame=Y;function y(t){if(!t||t!==t.trim())return null;var e=/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(t),r,i,a,n;if(e){var s=e[1].length<=4?e[1].replace(/./g,"$&$&"):e[1];return n=function(ue){return parseInt(s.substr(ue,2),16)},{r:n(0),g:n(2),b:n(4),a:s.length===8?Math.round(n(6)/255*1e3)/1e3:1}}if(r=/^rgba?\(\s*([^()]*?)\s*\)$/i.exec(t),!r)return null;var o=r[1].split("/");if(i=o[0].indexOf(",")>=0?o[0].split(","):o[0].trim().split(/\s+/),o.length===2&&i.push(o[1]),o.length>2||i.length<3||i.length>4)return null;var l=[];for(a=0;a<i.length;a++){var f=i[a].trim(),v=/^[+-]?(\d+\.?\d*|\.\d+)(e[+-]?\d+)?%?$/i.test(f)?parseFloat(f):f==="none"?0:NaN;if(isNaN(v))return null;/%$/.test(f)&&(v=v/100*(a<3?255:1)),l.push(a<3?Math.min(255,Math.max(0,Math.round(v))):Math.min(1,Math.max(0,v)))}return{r:l[0],g:l[1],b:l[2],a:l.length===4?l[3]:1}}function W(t){return"#"+[t.r,t.g,t.b].map(function(e){return(e<16?"0":"")+e.toString(16)}).join("")}function B(t,e,r){return r<1?"rgba("+e.r+", "+e.g+", "+e.b+", "+Math.round(r*100)/100+")":/^#/.test(t)||!t?W(e):"rgb("+e.r+", "+e.g+", "+e.b+")"}function _(t,e){var r=y(e),i=y(t);return r?B(t,r,i?i.a:1):t}function T(t,e){var r=y(t);return r&&isFinite(e)?B(t,r,Math.min(100,Math.max(0,Math.round(e)))/100):t}g.colorOf=y,g.pickSwatch=_,g.withOpacity=T;var F=function(t){var e=t.getAttribute("data-value");return e??(t.textContent||"").trim()},R=function(t){return Array.prototype.filter.call(t.children,function(e){return e.getAttribute("role")==="tab"})};function z(t,e){var r=t.parentNode,i=R(r),a=i.some(function(n){return n.hasAttribute("tabindex")});i.forEach(function(n){var s=n===t;n.setAttribute("aria-selected",String(s)),n.classList.contains("gemboss-ei-tab")&&n.classList.toggle("on",s),a&&n.setAttribute("tabindex",s?"0":"-1");var o=n.getAttribute("aria-controls"),l=o&&document.getElementById(o);l&&(l.hidden=!s)}),e&&t.focus(),u(t,"gemboss:editor-tab",{tab:F(t),index:i.indexOf(t)})}function Z(t,e){if(!(e.key!=="ArrowRight"&&e.key!=="ArrowLeft")){var r=R(t);if(r.some(function(n){return n.hasAttribute("tabindex")})){var i=r.findIndex(function(n){return n.getAttribute("aria-selected")==="true"}),a=e.key==="ArrowRight"?(i+1)%r.length:(i-1+r.length)%r.length;e.preventDefault(),z(r[a],!0)}}}function ee(t){var e=t.getAttribute("data-open")!=="open";t.setAttribute("data-open",e?"open":"closed"),t.setAttribute("aria-expanded",String(e));var r=t.querySelector(".gemboss-ei-chev");r&&(r.textContent=e?"\u25BE":"\u25B8"),u(t,"gemboss:editor-group",{open:e})}function te(t){var e=t.closest(".gemboss-ei-seg");Array.prototype.forEach.call(e.querySelectorAll("button"),function(r){var i=r===t;r.classList.toggle("active",i),r.classList.toggle("on",i),r.hasAttribute("aria-pressed")&&r.setAttribute("aria-pressed",String(i))}),u(e,"gemboss:editor-seg",{value:F(t)})}var re={text:"Text",accent:"Accent","accent-text":"Accent text",muted:"Muted",background:"Background",surface:"Surface",border:"Border",success:"Success",warning:"Warning",danger:"Danger"},ie=0;function ae(t){var e=t.trim(),r=/^var\(\s*--gem-color-([a-z-]+)\s*\)$/i.exec(e);if(r){var i=r[1].toLowerCase();return"Theme color: "+(re[i]||i.charAt(0).toUpperCase()+i.slice(1).replace(/-/g," "))+". Type a color to use your own."}return/^var\(/i.test(e)?"A theme value. Type a color to use your own.":"Kept as written: the swatch shows one color (hex, rgb, hsl or oklch), not a gradient or a name."}function L(t){var e=t.querySelector(".gemboss-ei-swatch"),r=t.querySelector("input:not([type=color]):not([data-alpha])");if(!(!e||!r)){var i=r.value,a=y(i),n=!i||!!a;e.value=a?W(a):"#000000";var s=t.querySelector("input[data-alpha]");a&&!s&&(s=document.createElement("input"),s.className="gemboss-field gemboss-ei-inp",s.setAttribute("data-alpha",""),s.setAttribute("inputmode","numeric"),s.setAttribute("aria-label",(e.getAttribute("aria-label")||"Color swatch").replace(/ swatch$/,"")+" opacity"),s.style.flex="0 0 64px",r.parentNode.insertBefore(s,r.nextSibling)),s&&(s.hidden=!a,a&&(s.value=Math.round(a.a*100)+"%"),s.disabled=r.disabled),e.disabled=r.disabled||!n;var o=t.querySelector(".gemboss-ei-color-why");if(n){e.removeAttribute("title"),e.removeAttribute("aria-describedby"),o&&o.parentNode.removeChild(o);return}var l=ae(i);e.title=l,o||(o=document.createElement("p"),o.className="gemboss-ei-color-why",t.appendChild(o)),o.id||(o.id="gemboss-ei-color-why-"+ ++ie),o.textContent=l,e.setAttribute("aria-describedby",o.id)}}function ne(t){for(var e=t.labels||[],r=null,i=0;i<e.length&&!r;i++)r=e[i].querySelector(".gemboss-ei-rangeval");r&&(r.textContent=t.value+(r.getAttribute("data-suffix")||""))}function se(t){var e={},r=["t","r","b","l"];return Array.prototype.forEach.call(t.querySelectorAll("input"),function(i,a){e[r[a]]=i.value.trim()}),e}function $(t){return'url("'+t.replace(/["\\]/g,"\\$&").replace(/[\u0000-\u001f\u007f]/g,function(e){return"\\"+e.charCodeAt(0).toString(16)+" "})+'")'}g.cssUrl=$;function oe(t,e){var r=t.querySelector(".gemboss-ei-img-thumb");r&&(r.style.backgroundImage=e?$(e):"",r.textContent=e?"":"\u2014")}function D(t,e){var r=t.getAttribute("data-gb-value")!=null?t.getAttribute("data-gb-value"):e.defaultValue;e.value!==r&&(t.setAttribute("data-gb-value",e.value),oe(t,e.value),u(t,"gemboss:editor-image",{value:e.value}))}var E=typeof WeakSet=="function"?new WeakSet:null,x=function(t){return t.__gbPanel?!1:(t.__gbPanel=!0,!0)};function le(t){var e=t||document;return(E?E.has(e):e._gbPanel)?0:(E?E.add(e):e._gbPanel=!0,Array.prototype.forEach.call(e.querySelectorAll(".gemboss-ei-color"),L),e.addEventListener("click",function(r){var i=r.target,a;if(!(!i.closest||!x(r))){if(a=i.closest('.gemboss-ec-tabs > [role="tab"], .gemboss-ei-tabs > [role="tab"]'))return z(a,!1);if(a=i.closest(".gemboss-ei-gh"))return ee(a);if((a=i.closest(".gemboss-ei-seg button"))&&!a.disabled)return te(a);if((a=i.closest("[data-gb-image-clear]"))&&!a.disabled){var n=a.closest(".gemboss-ei-img"),s=n&&n.querySelector(".gemboss-ei-img-ctl input");s&&(s.value="",D(n,s))}}}),e.addEventListener("keydown",function(r){var i=r.target;if(!(!i.closest||!x(r))){var a=i.closest('.gemboss-ec-tabs[role="tablist"], .gemboss-ei-tabs[role="tablist"]');if(a&&i.getAttribute("role")==="tab")return Z(a,r);r.key==="Enter"&&i.tagName==="INPUT"&&i.type!=="color"&&i.type!=="range"&&i.closest(".gemboss-ei-color, .gemboss-ei-edgebox, .gemboss-ei-img")&&(r.preventDefault(),i.blur())}}),e.addEventListener("input",function(r){var i=r.target;if(!(!i.closest||!x(r))){if(i.classList.contains("gemboss-ei-range")){ne(i),u(i,"gemboss:editor-range",{value:Number(i.value)});return}if(i.classList.contains("gemboss-ei-swatch")){var a=i.closest(".gemboss-ei-color"),n=a&&a.querySelector("input:not([type=color]):not([data-alpha])"),s=n?_(n.value,i.value):i.value;n&&(n.value=s),u(a||i,"gemboss:editor-color",{value:s})}}}),e.addEventListener("focusout",function(r){var i=r.target;if(!(!i.closest||i.tagName!=="INPUT"||!x(r))){var a;if(i.hasAttribute("data-alpha")&&(a=i.closest(".gemboss-ei-color"))){var n=a.querySelector("input:not([type=color]):not([data-alpha])"),s=Number(String(i.value).replace(/%/g,"").trim()),o=n&&String(i.value).trim()&&isFinite(s)?T(n.value,s):null;o&&o!==n.value?(n.value=o,L(a),u(a,"gemboss:editor-color",{value:o})):L(a);return}if(i.type!=="color"&&(a=i.closest(".gemboss-ei-color"))){L(a),u(a,"gemboss:editor-color",{value:i.value});return}if(a=i.closest(".gemboss-ei-edgebox")){u(a,"gemboss:editor-edges",se(a));return}(a=i.closest(".gemboss-ei-img"))&&D(a,i)}}),1)}g.initEditorPanel=le})(typeof globalThis<"u"?globalThis:this);
package/src/index.ts CHANGED
@@ -57,7 +57,7 @@ export { ThemeToggle } from "./theme";
57
57
  export { THEME_KEY, themeScript } from "./theme-script";
58
58
  export { Reactions, ReactionPicker, type ReactionsProps } from "./reactions";
59
59
  export { ActionCard, ActionCardItem, ActionCardSection, ActionChange, type ActionCardItemProps, type ActionCardOutcome, type ActionCardProps } from "./action-card";
60
- export { isReactionEmoji, QUICK_REACTIONS, REACTION_EMOJI, REACTION_GROUPS, reactionLabel, reactionWords, searchReactions, toggleReaction, type Reaction } from "./reactions-data";
60
+ export { FREQUENT_MAX, frequentReactions, isReactionEmoji, QUICK_REACTIONS, REACTION_EMOJI, REACTION_GROUPS, reactionLabel, reactionWords, searchReactions, SUGGESTED_REACTIONS, toggleReaction, type Reaction } from "./reactions-data";
61
61
  // The team chat layer (20/10/2026, README "The team chat layer"): GemCare's channel chat for every surface, with
62
62
  // team-chat.css its own entry. The row rules (turns, day separators, the "New messages" line) are plain data.
63
63
  export {
@@ -66,4 +66,4 @@ export {
66
66
  type ChannelItemProps, type ChannelKind, type ChannelRailProps, type ChatHeaderProps, type ComposerProps, type MessageListProps, type MessageProps,
67
67
  type PeoplePanelProps, type PersonRowProps, type Presence, type TeamChatLabels, type TeamChatPanel, type TeamChatProps, type TeamChatView, type ThreadPanelProps,
68
68
  } from "./team-chat";
69
- export { chatDayKey, chatDayWord, teamChatRows, type ChatRow, type ChatRowMessage, type ChatRowOptions } from "./team-chat-data";
69
+ export { chatDayISO, chatDayKey, chatDayWord, teamChatRows, type ChatRow, type ChatRowMessage, type ChatRowOptions } from "./team-chat-data";
package/src/portal.tsx CHANGED
@@ -93,13 +93,15 @@ export type LeaderRow = {
93
93
 
94
94
  const MEDALS = ["🥇", "🥈", "🥉"];
95
95
  const PODIUM_SLOT = ["gb-podium-slot--1", "gb-podium-slot--2", "gb-podium-slot--3"];
96
+ /** What screen readers hear before a name ("Rank 1"); pass `rankLabel` in the page's language ("Hạng 1", "第 1 名"). */
97
+ const RANK_LABEL = (rank: number) => `Rank ${rank}`;
96
98
 
97
99
  /**
98
100
  * A ranked list of people. Rows are links when they carry `href`; `unit` is read after the count by
99
101
  * screen readers ("163 votes") and can be shown with `showUnit`. Empty → nothing (a "top" list of
100
102
  * zeros looks dead; GemCommunity hides it the same way).
101
103
  */
102
- export function Leaderboard({ title, label, caption, rows, unit, showUnit = false, viewAll, as: L = "a", className, headingLevel = 2, id, currentLabel = "you" }: {
104
+ export function Leaderboard({ title, label, caption, rows, unit, showUnit = false, viewAll, as: L = "a", className, headingLevel = 2, id, currentLabel = "you", rankLabel = RANK_LABEL }: {
103
105
  /** The visible heading. Leave it out on a page whose own <h1> already names the board; give `label`. */
104
106
  title?: React.ReactNode; label?: string; caption?: React.ReactNode; rows: LeaderRow[]; unit?: string; showUnit?: boolean;
105
107
  viewAll?: { href: string; label: React.ReactNode }; as?: LinkLike; className?: string; headingLevel?: 2 | 3;
@@ -107,6 +109,8 @@ export function Leaderboard({ title, label, caption, rows, unit, showUnit = fals
107
109
  id?: string;
108
110
  /** Read by screen readers after the viewer's own name ("Minh, you"). "" when the name already says it on screen. */
109
111
  currentLabel?: string;
112
+ /** Read by screen readers before each name: `(n) => "Rank " + n` unless given. */
113
+ rankLabel?: (rank: number) => string;
110
114
  }) {
111
115
  if (!rows.length) return null;
112
116
  const H = `h${headingLevel}` as "h2" | "h3";
@@ -125,7 +129,7 @@ export function Leaderboard({ title, label, caption, rows, unit, showUnit = fals
125
129
  const inner = (
126
130
  <>
127
131
  <span className={cx("gb-leaderboard-rank", !MEDALS[i] && "is-number")}>
128
- <span aria-hidden="true">{MEDALS[i] ?? i + 1}</span><span className="gb-sr-only">Rank {i + 1}: </span>
132
+ <span aria-hidden="true">{MEDALS[i] ?? i + 1}</span><span className="gb-sr-only">{rankLabel(i + 1)}: </span>
129
133
  </span>
130
134
  <Avatar name={r.name} src={r.avatar} />
131
135
  <span className="gb-leaderboard-name">{r.name}{r.current && currentLabel ? <span className="gb-sr-only">, {currentLabel}</span> : null}</span>
@@ -147,17 +151,19 @@ export function Leaderboard({ title, label, caption, rows, unit, showUnit = fals
147
151
  );
148
152
  }
149
153
 
150
- /** The first three as a podium, drawn second · first · third. */
151
- export function Podium({ rows, unit, as: L = "a", className }: { rows: LeaderRow[]; unit?: string; as?: LinkLike; className?: string }) {
154
+ /** The first three as a podium, drawn second · first · third. `label` names the list for screen readers ("Top three"). */
155
+ export function Podium({ rows, unit, as: L = "a", className, label = "Top three", rankLabel = RANK_LABEL }: {
156
+ rows: LeaderRow[]; unit?: string; as?: LinkLike; className?: string; label?: string; rankLabel?: (rank: number) => string;
157
+ }) {
152
158
  if (!rows.length) return null;
153
159
  return (
154
- <ol className={cx("gb-podium", className)} aria-label="Top three">
160
+ <ol className={cx("gb-podium", className)} aria-label={label}>
155
161
  {[1, 0, 2].map((idx) => {
156
162
  const r = rows[idx];
157
163
  if (!r) return <li key={idx} className="gb-podium-slot is-empty" aria-hidden="true" />;
158
164
  const body = (
159
165
  <>
160
- <span className="gb-podium-medal"><span aria-hidden="true">{MEDALS[idx]}</span><span className="gb-sr-only">Rank {idx + 1}: </span></span>
166
+ <span className="gb-podium-medal"><span aria-hidden="true">{MEDALS[idx]}</span><span className="gb-sr-only">{rankLabel(idx + 1)}: </span></span>
161
167
  <Avatar name={r.name} src={r.avatar} size={56} />
162
168
  <span className="gb-podium-name">{r.name}</span>
163
169
  <span className="gb-podium-value">{r.value}{unit ? <span className="gb-podium-unit"> {unit}</span> : null}</span>
@@ -23,6 +23,19 @@ const KEYS = new Map<string, string>(REACTION_GROUPS.flatMap(([, items]) => item
23
23
  /** The quick pair shown on hover next to "Add reaction" (GemCare's 👍 Like and ✅ Done). */
24
24
  export const QUICK_REACTIONS: readonly string[] = ["👍", "✅"];
25
25
 
26
+ /** The picker's "Frequently used" row holds this many. */
27
+ export const FREQUENT_MAX = 9;
28
+ /** What fills "Frequently used" until this browser has used that many (Chris 20/10/2026: GemCare's row always held nine, and a
29
+ * row of one or two for a new person read as empty). The quick pair first, then the reactions a team reaches for most. */
30
+ export const SUGGESTED_REACTIONS: readonly string[] = ["👍", "✅", "🙏", "🎉", "😂", "❤️", "👀", "🙌", "🔥"];
31
+
32
+ /** "Frequently used": what this browser used, most recent first, then the suggestions it has not used, up to FREQUENT_MAX. */
33
+ export function frequentReactions(recent: readonly string[]): string[] {
34
+ const out: string[] = [];
35
+ for (const e of [...recent, ...SUGGESTED_REACTIONS]) if (isReactionEmoji(e) && !out.includes(e) && out.length < FREQUENT_MAX) out.push(e);
36
+ return out;
37
+ }
38
+
26
39
  export function isReactionEmoji(e: unknown): e is string {
27
40
  return typeof e === "string" && KEYS.has(e);
28
41
  }
package/src/reactions.tsx CHANGED
@@ -10,8 +10,8 @@
10
10
  * count does not), the viewer's own outlined and filled (not only coloured: two chips that differ only in hue look the
11
11
  * same to a colour-blind reader). No chips, no row.
12
12
  * - "Add reaction": opens the picker IN FLOW under the row (not floating: a layer in a scrolling column has to fight the
13
- * bottom edge; a row in flow is scrolled to by the browser). Frequently used first (this browser's, else the quick
14
- * pair), then the groups; search by words; arrows move in the grid, Enter in the search picks the first, Escape closes
13
+ * bottom edge; a row in flow is scrolled to by the browser). Frequently used first (this browser's, filled up to nine
14
+ * with SUGGESTED_REACTIONS), then the groups; search by words; arrows move in the grid, Enter in the search picks the first, Escape closes
15
15
  * and gives focus back to the button.
16
16
  *
17
17
  * Each target is 44px (a control, CLAUDE.md §9.1) while the visible pill is 26px: transparent padding carries the rest,
@@ -20,7 +20,7 @@
20
20
  import "./reactions.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
21
21
  import React, { useEffect, useId, useRef, useState } from "react";
22
22
  import { Icon } from "./icons";
23
- import { QUICK_REACTIONS, REACTION_GROUPS, reactionLabel, reactionWords, searchReactions, type Reaction } from "./reactions-data";
23
+ import { frequentReactions, REACTION_GROUPS, reactionLabel, reactionWords, searchReactions, type Reaction } from "./reactions-data";
24
24
 
25
25
  const RECENT_KEY = "gemboss-ui:reactions-recent";
26
26
  const RECENT_MAX = 9;
@@ -91,11 +91,11 @@ export function Reactions({ items, onToggle, target = "this", canReact = true, o
91
91
 
92
92
  export function ReactionPicker({ items, onPick, onClose, id }: { items: readonly Reaction[]; onPick: (emoji: string) => void; onClose: () => void; id?: string }) {
93
93
  const [q, setQ] = useState("");
94
- const [recent, setRecent] = useState<readonly string[]>(QUICK_REACTIONS);
94
+ const [recent, setRecent] = useState<readonly string[]>(() => frequentReactions([]));
95
95
  const search = useRef<HTMLInputElement>(null);
96
96
  const body = useRef<HTMLDivElement>(null);
97
97
  // Storage is read after mount: the server render and the first client render must draw the same picker.
98
- useEffect(() => { const r = readRecent(); if (r.length) setRecent(r); search.current?.focus(); }, []);
98
+ useEffect(() => { const r = readRecent(); if (r.length) setRecent(frequentReactions(r)); search.current?.focus(); }, []);
99
99
  const mine = (e: string) => items.some((r) => r.emoji === e && r.mine);
100
100
  const btn = (e: string) => (
101
101
  <button key={e} type="button" className="ui-reaction-picker__btn" title={reactionWords(e).split(" ")[0] || e}
@@ -7,8 +7,9 @@
7
7
  * The rules (GemCare origin/main, 20/10/2026):
8
8
  * - A message is GROUPED under the one before it (no avatar, no name line) when the same person wrote both, on the same
9
9
  * day, less than 5 minutes apart, and it is not a reply (a reply starts a turn of its own: it answers someone else).
10
- * - A day separator comes before the first message and before the first message of each new day (local time of the
11
- * viewer). A new day also breaks a group: two lines either side of midnight are two turns.
10
+ * - A day separator comes before the first message and before the first message of each new day: the viewer's local day,
11
+ * or the calendar day of `timeZone` when given. A new day also breaks a group: two lines either side of midnight are
12
+ * two turns.
12
13
  * - The "New messages" line comes before the first message someone ELSE wrote that the viewer has not read. The
13
14
  * viewer's own messages after the mark are not "new". The line also breaks a group, so the first new message
14
15
  * always shows who wrote it.
@@ -36,23 +37,55 @@ export type ChatRowOptions<M> = {
36
37
  isUnread?: (message: M) => boolean;
37
38
  /** Messages closer than this are one turn. Default 5 minutes, as GemCare. */
38
39
  groupWithinMs?: number;
40
+ /**
41
+ * An IANA zone ("Asia/Ho_Chi_Minh"): days are that zone's calendar days instead of the process's local ones. Give it on
42
+ * a screen the server renders first: the server's zone (UTC on Railway) is not the member's, so a message at 00:30 in
43
+ * Hanoi fell on "yesterday" on the server and "today" in the browser, and the two drew different day separators (React
44
+ * #418, GemCommunity /chat 20/10/2026). An unknown name falls back to the local day.
45
+ */
46
+ timeZone?: string;
39
47
  };
40
48
 
41
49
  const toDate = (at: string | number | Date) => (at instanceof Date ? at : new Date(at));
42
50
 
43
- /** The viewer's local calendar day, as a key: two dates with the same key are the same day. */
44
- export function chatDayKey(at: string | number | Date): string {
51
+ const ZONE_DAY = new Map<string, Intl.DateTimeFormat | null>();
52
+ /** [year, month 1–12, day] of `d` in `timeZone`, or in local time when there is no zone, it is unknown, or `d` is invalid. */
53
+ function dayParts(at: string | number | Date, timeZone?: string): [number, number, number] {
45
54
  const d = toDate(at);
46
- return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
55
+ if (timeZone) {
56
+ let f = ZONE_DAY.get(timeZone);
57
+ if (f === undefined) {
58
+ try { f = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "numeric", day: "numeric" }); } catch { f = null; }
59
+ ZONE_DAY.set(timeZone, f);
60
+ }
61
+ if (f && !Number.isNaN(d.getTime())) {
62
+ const p: Record<string, number> = {};
63
+ for (const x of f.formatToParts(d)) if (x.type !== "literal") p[x.type] = Number(x.value);
64
+ return [p.year, p.month, p.day];
65
+ }
66
+ }
67
+ return [d.getFullYear(), d.getMonth() + 1, d.getDate()];
68
+ }
69
+
70
+ /** The calendar day (local, or of `timeZone`), as a key: two dates with the same key are the same day. */
71
+ export function chatDayKey(at: string | number | Date, timeZone?: string): string {
72
+ const [y, m, d] = dayParts(at, timeZone);
73
+ return `${y}-${m}-${d}`;
74
+ }
75
+
76
+ /** The same day as ISO 8601 ("2026-10-21"), for a day separator's `<time dateTime>`. */
77
+ export function chatDayISO(at: string | number | Date, timeZone?: string): string {
78
+ const [y, m, d] = dayParts(at, timeZone);
79
+ return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
47
80
  }
48
81
 
49
82
  /** "Today", "Yesterday", or null (the caller formats older days with its own locale, e.g. "Mon, Sep 28"). */
50
- export function chatDayWord(at: string | number | Date, now: Date = new Date()): "Today" | "Yesterday" | null {
51
- const key = chatDayKey(at);
52
- if (key === chatDayKey(now)) return "Today";
53
- const y = new Date(now);
54
- y.setDate(y.getDate() - 1);
55
- return key === chatDayKey(y) ? "Yesterday" : null;
83
+ export function chatDayWord(at: string | number | Date, now: Date = new Date(), timeZone?: string): "Today" | "Yesterday" | null {
84
+ const key = chatDayKey(at, timeZone);
85
+ const [y, m, d] = dayParts(now, timeZone);
86
+ if (key === `${y}-${m}-${d}`) return "Today";
87
+ const p = new Date(Date.UTC(y, m - 1, d - 1)); // calendar arithmetic: the day before, whatever the zone
88
+ return key === `${p.getUTCFullYear()}-${p.getUTCMonth() + 1}-${p.getUTCDate()}` ? "Yesterday" : null;
56
89
  }
57
90
 
58
91
  /** The rows to draw, in order: day separators, the "New messages" line, and each message with its `grouped` flag. */
@@ -65,8 +98,8 @@ export function teamChatRows<M extends ChatRowMessage>(messages: readonly M[], o
65
98
  messages.forEach((m, i) => {
66
99
  const prev = i > 0 ? messages[i - 1] : undefined;
67
100
  const at = toDate(m.at);
68
- const newDay = !prev || chatDayKey(prev.at) !== chatDayKey(at);
69
- if (newDay) rows.push({ kind: "day", key: chatDayKey(at), date: at });
101
+ const newDay = !prev || chatDayKey(prev.at, opts.timeZone) !== chatDayKey(at, opts.timeZone);
102
+ if (newDay) rows.push({ kind: "day", key: chatDayKey(at, opts.timeZone), date: at });
70
103
  const isFirstNew = i === firstNew;
71
104
  if (isFirstNew) rows.push({ kind: "new" });
72
105
  const grouped = Boolean(