@gemboss/ui 0.45.0 → 0.47.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.
@@ -16,6 +16,10 @@
16
16
  *
17
17
  * Presentational and controlled: no state of its own, so it renders on the server. The choices are real radio inputs (a keyboard
18
18
  * and a screen reader get a radio group, the arrow keys move the selection); the dot and the highlight are drawn by the card.
19
+ *
20
+ * A choice made by LOOKING (unreleased, after 0.46.0; the store's logo, GemBoss Brand): `media` draws a picture above the choice's
21
+ * words, `layout="tiles"` puts the choices side by side (one column on a phone), and `disabled` shows a choice that cannot be taken
22
+ * (an option that failed its checks: seen, greyed, its radio off, and the confirm stays off while it is the value).
19
23
  * Its stylesheet travels with it (`question-card.css`, own entry; `@gemboss/ui/question-card.css` for a plain-HTML surface that
20
24
  * writes the same classes).
21
25
  */
@@ -35,6 +39,12 @@ export interface QuestionChoice {
35
39
  note?: ReactNode;
36
40
  /** The assistant's best guess: gets the pick tag. At most one choice should carry it, and it goes first. */
37
41
  pick?: boolean;
42
+ /** A picture of the choice, above its words: for a choice a person makes by looking (a logo, a layout). Phrasing content only
43
+ * (spans, images): the choice is a `<label>`. */
44
+ media?: ReactNode;
45
+ /** Shown but not choosable (an option that failed): its radio is disabled, the row greyed, and the confirm stays off while it is
46
+ * the value. A surface should not make a disabled choice the `pick` or the starting `value`. */
47
+ disabled?: boolean;
38
48
  }
39
49
 
40
50
  /** One, two or three choices. A fourth does not type-check. */
@@ -71,17 +81,21 @@ export interface QuestionCardProps {
71
81
  fine?: ReactNode;
72
82
  /** While the confirm is being saved: the card stops taking input. */
73
83
  busy?: boolean;
84
+ /** `rows` (default): one choice under the other. `tiles`: side by side, each with its `media` on top; one column on a phone. */
85
+ layout?: "rows" | "tiles";
74
86
  className?: string;
75
87
  }
76
88
 
77
89
  export function QuestionCard({
78
90
  id, question, eyebrow, evidence, art, choices, value, onChange, confirmLabel, onConfirm, pickLabel = "Gem's pick",
79
- onOther, otherLabel = "Type my own", onSkip, skipLabel = "Skip", fine, busy, className,
91
+ onOther, otherLabel = "Type my own", onSkip, skipLabel = "Skip", fine, busy, layout = "rows", className,
80
92
  }: QuestionCardProps) {
81
93
  const list: readonly QuestionChoice[] = choices.slice(0, MAX_QUESTION_CHOICES);
82
94
  const qid = `${id}-q`;
95
+ // The confirm commits the value only when it is a choice that can be taken: never a disabled one, never one that is not listed.
96
+ const confirmable = list.some((c) => c.id === value && !c.disabled);
83
97
  return (
84
- <section className={cx("ui-qcard", className)} aria-labelledby={qid} aria-busy={busy || undefined}>
98
+ <section className={cx("ui-qcard", layout === "tiles" && "ui-qcard--tiles", className)} aria-labelledby={qid} aria-busy={busy || undefined}>
85
99
  <header className="ui-qcard__head">
86
100
  {art ? <div className="ui-qcard__art" aria-hidden>{art}</div> : null}
87
101
  <div className="ui-qcard__ask">
@@ -93,8 +107,9 @@ export function QuestionCard({
93
107
  <div className="ui-qcard__choices" role="radiogroup" aria-labelledby={qid}>
94
108
  {list.map((c) => (
95
109
  <label className="ui-qcard__choice" key={c.id}>
96
- <input className="ui-qcard__input" type="radio" name={id} value={c.id} checked={value === c.id} disabled={busy}
110
+ <input className="ui-qcard__input" type="radio" name={id} value={c.id} checked={value === c.id} disabled={busy || c.disabled}
97
111
  onChange={() => onChange(c.id)} />
112
+ {c.media ? <span className="ui-qcard__media">{c.media}</span> : null}
98
113
  <span className="ui-qcard__dot" aria-hidden />
99
114
  <span className="ui-qcard__text">
100
115
  <span className="ui-qcard__label">{c.label}</span>
@@ -105,7 +120,7 @@ export function QuestionCard({
105
120
  ))}
106
121
  </div>
107
122
  <div className="ui-qcard__foot">
108
- <button type="button" className="ui-btn ui-btn--primary ui-qcard__confirm" disabled={busy || !list.some((c) => c.id === value)}
123
+ <button type="button" className="ui-btn ui-btn--primary ui-qcard__confirm" disabled={busy || !confirmable}
109
124
  onClick={() => onConfirm(value)}>{confirmLabel}</button>
110
125
  {onOther ? <button type="button" className="ui-btn ui-btn--ghost" disabled={busy} onClick={onOther}>{otherLabel}</button> : null}
111
126
  {onSkip ? <button type="button" className="ui-btn ui-btn--ghost" disabled={busy} onClick={onSkip}>{skipLabel}</button> : null}
@@ -0,0 +1 @@
1
+ (function(g){"use strict";var q=g.gembossUI;if(!q){g.console&&g.console.error("@gemboss/ui session-rail-plain.js: load kit-plain.js first (it defines gembossUI)");return}if(q.sessionRail)return;var S="http://www.w3.org/2000/svg",j=function(r){return typeof Node<"u"&&r instanceof Node};function B(r,t){var i={},n;for(n in r)i[n]=r[n];for(n in t)i[n]=t[n];return i}function l(r,t,i){var n=document.createElement(r);if(t)for(var o in t){var d=t[o];d==null||d===!1||(o==="className"?n.className=d:n.setAttribute(o,d===!0?"":String(d)))}return(Array.isArray(i)?i:i==null?[]:[i]).forEach(function(a){n.appendChild(j(a)?a:document.createTextNode(String(a)))}),n}function Q(r){try{return g.localStorage.getItem(r)}catch{return null}}function J(r,t){try{g.localStorage.setItem(r,t)}catch{}}var O={newChat:"New chat",expand:"Expand sidebar",collapse:"Collapse sidebar",expandTitle:"Expand",collapseTitle:"Collapse",searchPlaceholder:"Search conversations\u2026",searchLabel:"Search history",clearSearch:"Clear search",noneFound:"No conversations found",noneYet:"No conversations yet",openNow:"Open now",pinned:"Pinned",today:"Today",yesterday:"Yesterday",previous7:"Previous 7 days",previous30:"Previous 30 days",older:"Older",working:"working",options:"Options",save:"Save",cancel:"Cancel",pin:"Pin",unpin:"Unpin",rename:"Rename",exportMarkdown:"Export Markdown",remove:"Delete",confirmRemove:"Delete this chat?",justNow:"just now",minutesAgo:function(r){return r+" min ago"},hoursAgo:function(r){return r+" hr ago"},daysAgo:function(r){return r+" day"+(r>1?"s":"")+" ago"},dateLocale:"en-US"};function H(r,t,i){t=t||O;var n=Math.floor(((i===void 0?Date.now():i)-r)/6e4);if(n<1)return t.justNow;if(n<60)return t.minutesAgo(n);var o=Math.floor(n/60);if(o<24)return t.hoursAgo(o);var d=Math.floor(o/24);return d<7?t.daysAgo(d):new Date(r).toLocaleDateString(t.dateLocale)}function P(r){return String(r).toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g,"").replace(/đ/g,"d")}function _(r,t,i){t=t||O;var n=r.filter(function(p){return p.pinned}),o=r.filter(function(p){return!p.pinned}),d=new Date(i===void 0?Date.now():i),a=new Date(d.getFullYear(),d.getMonth(),d.getDate()).getTime(),A=864e5,T=[{label:t.today,min:a},{label:t.yesterday,min:a-A},{label:t.previous7,min:a-7*A},{label:t.previous30,min:a-30*A},{label:t.older,min:-1/0}],f=T.map(function(p){return{label:p.label,items:[]}});o.forEach(function(p){for(var v=-1,L=0;L<T.length;L++)if(p.updatedAt>=T[L].min){v=L;break}f[v===-1?f.length-1:v].items.push(p)});var w=n.length?[{label:t.pinned,items:n}]:[];return w.concat(f.filter(function(p){return p.items.length>0}))}q.assistantSessions={relativeTime:H,normalizeForSearch:P,groupSessions:_,labels:O};function b(r){return function(t){var i=document.createElementNS(S,"svg");i.setAttribute("viewBox","0 0 20 20"),t&&i.setAttribute("class",t),i.setAttribute("aria-hidden","true"),i.setAttribute("focusable","false");var n=document.createElementNS(S,"g");return[["fill","none"],["stroke","currentColor"],["stroke-width","1.6"],["stroke-linecap","round"],["stroke-linejoin","round"]].forEach(function(o){n.setAttribute(o[0],o[1])}),r.forEach(function(o){var d=document.createElementNS(S,o.tag||"path");for(var a in o)a!=="tag"&&d.setAttribute(a,o[a]);n.appendChild(d)}),i.appendChild(n),i}}var X={compose:b([{d:"M11 4H5.5A1.5 1.5 0 0 0 4 5.5v9A1.5 1.5 0 0 0 5.5 16h9a1.5 1.5 0 0 0 1.5-1.5V9"},{d:"M14.5 3.5l2 2L10 12H8v-2z"}]),chevronLeft:b([{d:"M12 5l-5 5 5 5"}]),chevronRight:b([{d:"M8 5l5 5-5 5"}]),search:b([{tag:"circle",cx:"9",cy:"9",r:"5"},{d:"M13 13l3.5 3.5"}]),close:b([{d:"M6 6l8 8M14 6l-8 8"}]),check:b([{d:"M4.5 10.5l3.5 3.5 7.5-8"}]),more:b([{tag:"circle",cx:"5",cy:"10",r:".8"},{tag:"circle",cx:"10",cy:"10",r:".8"},{tag:"circle",cx:"15",cy:"10",r:".8"}]),pin:b([{d:"M8 3.5h4l-.5 5 2.5 2.5H6l2.5-2.5z"},{d:"M10 11v5.5"}]),pinFilled:function(r){var t=document.createElementNS(S,"svg");t.setAttribute("viewBox","0 0 20 20"),r&&t.setAttribute("class",r),t.setAttribute("aria-hidden","true"),t.setAttribute("focusable","false");var i=document.createElementNS(S,"path");i.setAttribute("d","M8 3.5h4l-.5 5 2.5 2.5H6l2.5-2.5z"),i.setAttribute("fill","currentColor");var n=document.createElementNS(S,"path");return n.setAttribute("d","M10 11v5.5"),n.setAttribute("stroke","currentColor"),n.setAttribute("stroke-width","1.6"),n.setAttribute("stroke-linecap","round"),t.appendChild(i),t.appendChild(n),t},edit:b([{d:"M4 16h3l8.5-8.5-3-3L4 13z"}]),exportIcon:b([{d:"M10 3.5v9M6.5 7L10 3.5 13.5 7"},{d:"M4 12.5v3h12v-3"}]),remove:b([{d:"M5 6h10M8 6V4.5h4V6M6.5 6l.7 9.5h5.6l.7-9.5"}])};q.sessionRail=function(r){r=r||{};var t={sessions:[],unlistedIds:[],activeId:void 0,pendingId:null,busyIds:[],collapsed:!1,footer:null,titleMaxLength:200,newChatTitle:void 0},i={},n=O,o=X,d=r.storageKey||"",a={query:"",editingId:null,editValue:"",menuId:null,menuTop:0,menuLeft:0,confirmDeleteId:null},A=[];function T(e){["sessions","unlistedIds","busyIds"].forEach(function(s){e[s]!==void 0&&(t[s]=e[s]||[])}),["activeId","pendingId","footer","newChatTitle"].forEach(function(s){e[s]!==void 0&&(t[s]=e[s])}),e.collapsed!==void 0&&(t.collapsed=!!e.collapsed),e.titleMaxLength!==void 0&&(t.titleMaxLength=e.titleMaxLength||200),e.labels&&(n=B(n,e.labels)),e.icons&&(o=B(o,e.icons)),["onToggleCollapsed","onNewChat","onOpen","onRename","onTogglePin","onDelete","onExport"].forEach(function(s){e[s]!==void 0&&(i[s]=e[s])})}T(r),r.collapsed===void 0&&d&&(t.collapsed=Q(d)==="1");var f=l("aside",{className:"gemboss-assistant-rail"}),w=l("button",{type:"button",className:"gemboss-assistant-newchat"});w.addEventListener("click",function(){i.onNewChat&&i.onNewChat()});var p=l("button",{type:"button",className:"gemboss-assistant-collapse"});p.addEventListener("click",function(){if(i.onToggleCollapsed){i.onToggleCollapsed();return}t.collapsed=!t.collapsed,d&&J(d,t.collapsed?"1":"0"),K()}),f.appendChild(l("div",{className:"gemboss-assistant-rail-top"},[w,p]));var v=l("div",{className:"gemboss-assistant-search gemboss-field gemboss-field--group"}),L=o.search(),y=l("input",{className:"gemboss-field__bare"}),V=null;y.addEventListener("input",function(){a.query=y.value,F(),E()}),y.addEventListener("keydown",function(e){e.key==="Escape"&&(e.preventDefault(),Y(""),y.blur())}),v.appendChild(L),v.appendChild(y);var C=l("div",{className:"gemboss-assistant-threads"});f.appendChild(C);var z=[];function Y(e){a.query=e,y.value=e,F(),E()}function F(){var e;a.query?(e=l("button",{type:"button",className:"gemboss-assistant-search-clear","aria-label":n.clearSearch},o.close()),e.addEventListener("click",function(){Y("")})):e=l("kbd",{className:"gemboss-assistant-kbd"},"/"),V?v.replaceChild(e,V):v.appendChild(e),V=e}function M(){a.menuId!==null&&(a.menuId=null,U(),E())}function U(){A.forEach(function(e){e()}),A=[]}function Z(){U();var e=function(u){var m=C.querySelector(".gemboss-assistant-menu");(!m||!m.contains(u.target))&&M()},s=function(){M()};document.addEventListener("mousedown",e),g.addEventListener("scroll",s,!0),g.addEventListener("resize",s),A.push(function(){document.removeEventListener("mousedown",e),g.removeEventListener("scroll",s,!0),g.removeEventListener("resize",s)})}function $(e,s){if(a.menuId===s){M();return}var u=e.getBoundingClientRect(),m=184,c=200,h=u.bottom+c+8>g.innerHeight;a.menuLeft=Math.max(8,Math.min(u.right-m,g.innerWidth-m-8)),a.menuTop=h?Math.max(8,u.top-6-c):u.bottom+6,a.confirmDeleteId=null,a.menuId=s,Z(),E()}function ee(e){a.editingId=e.id,a.editValue=e.title,E()}function W(){a.editingId=null,a.editValue="",E()}function G(e){var s=a.editValue.trim();a.editingId=null,E(),!(!s||s===e.title)&&i.onRename&&i.onRename(e.id,s)}function D(e,s,u,m){var c=l("button",{type:"button",role:"menuitem",className:m?"danger":null},[e,l("span",null,s)]);return c.addEventListener("click",u),c}function te(e){var s=a.editingId===e.id,u=l("div",{className:"gemboss-assistant-thread"+(e.id===t.activeId||e.id===t.pendingId?" active":"")+(s?" editing":"")+(a.menuId===e.id?" menu-open":"")});if(s){var m=l("input",{className:"gemboss-field",maxlength:String(t.titleMaxLength)});m.value=a.editValue,m.addEventListener("input",function(){a.editValue=m.value}),m.addEventListener("keydown",function(x){x.key==="Enter"?(x.preventDefault(),G(e)):x.key==="Escape"&&(x.preventDefault(),W())});var c=l("button",{type:"button",className:"gemboss-assistant-thread-act","aria-label":n.save},o.check());c.addEventListener("click",function(){G(e)});var h=l("button",{type:"button",className:"gemboss-assistant-thread-act","aria-label":n.cancel},o.close());return h.addEventListener("click",W),u.appendChild(l("div",{className:"gemboss-assistant-thread-edit"},[m,c,h])),u}var I=l("span",{className:"gemboss-assistant-thread-titlerow"});t.busyIds.indexOf(e.id)>=0&&I.appendChild(l("span",{className:"gemboss-assistant-thread-running","aria-label":n.working})),e.pinned&&I.appendChild(o.pinFilled("gemboss-assistant-pin-dot")),I.appendChild(l("span",{className:"gemboss-assistant-thread-title"},e.title));var k=l("button",{type:"button",className:"gemboss-assistant-thread-main",title:e.title},[I,l("span",{className:"gemboss-assistant-thread-meta"},H(e.updatedAt,n))]);k.addEventListener("click",function(){i.onOpen&&i.onOpen(e.id)}),k.gbRailKey=e.id+":main";var R=l("button",{type:"button",className:"gemboss-assistant-thread-act","aria-label":n.options},o.more());if(R.addEventListener("mousedown",function(x){x.stopPropagation()}),R.addEventListener("click",function(){$(R,e.id)}),R.gbRailKey=e.id+":more",u.appendChild(k),u.appendChild(l("div",{className:"gemboss-assistant-thread-actions"},R)),a.menuId===e.id){var N=l("div",{className:"gemboss-popover-surface gemboss-assistant-menu",role:"menu"});N.style.position="fixed",N.style.top=a.menuTop+"px",N.style.left=a.menuLeft+"px",a.confirmDeleteId===e.id?(N.appendChild(l("div",{className:"gemboss-assistant-menu-confirm"},n.confirmRemove)),N.appendChild(D(o.remove(),n.remove,function(){M(),i.onDelete&&i.onDelete(e.id)},!0)),N.appendChild(D(o.close(),n.cancel,function(){a.confirmDeleteId=null,E()}))):(i.onTogglePin&&N.appendChild(D(e.pinned?o.pin():o.pinFilled(),e.pinned?n.unpin:n.pin,function(){M(),i.onTogglePin(e)})),N.appendChild(D(o.edit(),n.rename,function(){M(),ee(e)})),i.onExport&&N.appendChild(D(o.exportIcon(),n.exportMarkdown,function(){M(),i.onExport(e.id)})),N.appendChild(D(o.remove(),n.remove,function(){a.confirmDeleteId=e.id,E()},!0))),u.appendChild(N)}return u}function ne(){var e=P(a.query.trim());if(e){var s=t.sessions.filter(function(c){return P(c.title+" "+(c.preview||"")).indexOf(e)>=0});return s.length?[{label:"",items:s}]:[]}var u=_(t.sessions,n),m=t.unlistedIds.filter(function(c){return!t.sessions.some(function(h){return h.id===c})}).map(function(c){return{id:c,title:n.newChat,updatedAt:Date.now(),pinned:!1}});return m.length?[{label:n.openNow,items:m}].concat(u):u}function E(){for(var e=document.activeElement&&C.contains(document.activeElement)?document.activeElement:null,s=!!e&&e.tagName==="INPUT",u=e?e.gbRailKey:null;C.firstChild;)C.removeChild(C.firstChild);var m=ne();m.length?m.forEach(function(h){var I=l("div",{className:"gemboss-assistant-group"});h.label&&I.appendChild(l("div",{className:"gemboss-assistant-group-label"},h.label)),h.items.forEach(function(k){I.appendChild(te(k))}),C.appendChild(I)}):C.appendChild(l("div",{className:"gemboss-assistant-threads-empty"},a.query?n.noneFound:n.noneYet)),u&&Array.prototype.some.call(C.querySelectorAll("button"),function(h){return h.gbRailKey!==u?!1:(h.focus({preventScroll:!0}),!0)});var c=C.querySelector(".gemboss-assistant-thread-edit input");if(c&&(s||a.editingId!==null)){c.focus();try{c.setSelectionRange(c.value.length,c.value.length)}catch{}}}function K(){for(f.className="gemboss-assistant-rail"+(t.collapsed?" is-collapsed":"");w.firstChild;)w.removeChild(w.firstChild);for(w.title=t.newChatTitle!==void 0&&t.newChatTitle!==null?t.newChatTitle:n.newChat,w.appendChild(o.compose()),w.appendChild(l("span",null,n.newChat));p.firstChild;)p.removeChild(p.firstChild);p.setAttribute("aria-label",t.collapsed?n.expand:n.collapse),p.title=t.collapsed?n.expandTitle:n.collapseTitle,p.appendChild(t.collapsed?o.chevronRight():o.chevronLeft()),t.sessions.length>0?(y.setAttribute("placeholder",n.searchPlaceholder),y.setAttribute("aria-label",n.searchLabel),y.value!==a.query&&(y.value=a.query),F(),v.parentNode!==f&&f.insertBefore(v,C)):v.parentNode===f&&f.removeChild(v),E(),z.forEach(function(e){e.parentNode===f&&f.removeChild(e)}),z=t.footer?(Array.isArray(t.footer)?t.footer:[t.footer]).filter(j):[],z.forEach(function(e){f.appendChild(e)})}return K(),{el:f,update:function(e){T(e||{}),K()},focusSearch:function(){v.parentNode===f&&y.focus()},destroy:function(){U(),f.parentNode&&f.parentNode.removeChild(f)}}}})(typeof globalThis<"u"?globalThis:this);
@@ -1 +1 @@
1
- .ui-setup,.ui-setup__inner{display:grid;gap:var(--ui-space-3, 12px)}.ui-setup__head,.ui-setup__row{display:flex;align-items:center;gap:var(--ui-space-3, 12px);list-style:none;cursor:pointer;border-radius:var(--ui-radius-md, 8px);color:inherit;transition:background-color .12s ease}.ui-setup__head::-webkit-details-marker,.ui-setup__row::-webkit-details-marker{display:none}div.ui-setup__head{cursor:auto}summary.ui-setup__head{padding:6px 8px;margin:-6px -8px 0}.ui-setup__title{margin:0;font-size:var(--ui-fs-body, 13px);line-height:20px;font-weight:650;color:var(--ui-text-strong, var(--ui-text))}.ui-setup__desc{margin:0;font-size:var(--ui-fs-body, 13px);line-height:20px;color:var(--ui-text-muted)}.ui-setup__row{padding:8px;margin:0 -8px}summary.ui-setup__head:hover,summary.ui-setup__row:hover{background:var(--ui-surface-2, #eceef2)}.ui-setup__row--static{cursor:auto}.ui-setup__row.ui-setup__row--static:after{content:none}summary.ui-setup__head:active,summary.ui-setup__row:active{background:var(--ui-border, #e5e7ea)}summary.ui-setup__head:focus-visible,summary.ui-setup__row:focus-visible{outline:2px solid var(--ui-focus, #5661f5);outline-offset:2px}summary.ui-setup__head:after,.ui-setup__row:after{content:"\25be";margin-left:auto;font-size:11px;color:var(--ui-text-muted);transition:transform .12s ease}.ui-setup:not([open])>summary.ui-setup__head:after,.ui-setup__step:not([open])>.ui-setup__row:after{transform:rotate(-90deg)}.ui-setup__bar{appearance:none;-webkit-appearance:none;display:block;width:100%;height:6px;border:0;border-radius:999px;overflow:hidden;background:var(--ui-border, #e5e7ea);color:var(--ui-accent, #1b1c1e)}.ui-setup__bar::-webkit-progress-bar{background:var(--ui-border, #e5e7ea);border-radius:inherit}.ui-setup__bar::-webkit-progress-value{background:currentColor;border-radius:inherit;transition:width .15s ease}.ui-setup__bar::-moz-progress-bar{background:currentColor;border-radius:inherit}.ui-setup__bar--done{color:var(--ui-success-fg, #007f5f)}.ui-setup__steps{display:grid;gap:2px}.ui-setup__mark{flex:0 0 16px;text-align:center;line-height:20px;color:var(--ui-text-muted)}.ui-setup__step[data-state=done] .ui-setup__mark{color:var(--ui-success-fg, #007f5f)}.ui-setup__label{min-width:0;overflow-wrap:anywhere;font-size:var(--ui-fs-body, 13px);line-height:20px;font-weight:550;color:var(--ui-text)}.ui-setup__step[data-state=done] .ui-setup__label{font-weight:450;color:var(--ui-text-muted)}.ui-setup__body{padding:4px 0 12px 28px;font-size:var(--ui-fs-body, 13px);line-height:20px;color:var(--ui-text)}.ui-setup__body>:first-child{margin-top:0}.ui-setup__body>:last-child{margin-bottom:0}@media (pointer: coarse),(max-width: 47.9975em){summary.ui-setup__head,.ui-setup__row{min-height:44px;box-sizing:border-box}}@media (prefers-reduced-motion: reduce){.ui-setup__head,.ui-setup__row,summary.ui-setup__head:after,.ui-setup__row:after,.ui-setup__bar::-webkit-progress-value{transition:none}}
1
+ .ui-setup{--ui-setup-done: var(--gemboss-success, #007f5f)}.ui-setup,.ui-setup__inner{display:grid;gap:var(--ui-space-3, 12px)}.ui-setup__head,.ui-setup__row{display:flex;align-items:center;gap:var(--ui-space-3, 12px);list-style:none;cursor:pointer;border-radius:var(--ui-radius-md, 8px);color:inherit;transition:background-color .12s ease}.ui-setup__head::-webkit-details-marker,.ui-setup__row::-webkit-details-marker{display:none}div.ui-setup__head{cursor:auto}summary.ui-setup__head{padding:6px 8px;margin:-6px -8px 0}.ui-setup__title{margin:0;font-size:var(--ui-fs-body, 13px);line-height:20px;font-weight:650;color:var(--ui-text-strong, var(--ui-text))}.ui-setup .ui-badge--warning{background:var(--gemboss-warning-soft);color:var(--gemboss-warning)}.ui-setup .ui-badge--success{background:var(--gemboss-success-soft);color:var(--gemboss-success-strong)}.ui-setup__desc{margin:0;font-size:var(--ui-fs-body, 13px);line-height:20px;color:var(--ui-text-muted)}.ui-setup__row{padding:8px;margin:0 -8px}summary.ui-setup__head:hover,summary.ui-setup__row:hover,.ui-setup__x:hover{background:var(--ui-surface-2, #eceef2)}.ui-setup__row--static{cursor:auto}.ui-setup__row.ui-setup__row--static:after{content:none}summary.ui-setup__head:active,summary.ui-setup__row:active,.ui-setup__x:active{background:var(--ui-border, #e5e7ea)}summary.ui-setup__head:focus-visible,summary.ui-setup__row:focus-visible,.ui-setup__x:focus-visible{outline:2px solid var(--ui-focus, #5661f5);outline-offset:2px}summary.ui-setup__head:after,.ui-setup__row:after{content:"\25be";margin-left:auto;font-size:11px;color:var(--ui-text-muted);transition:transform .12s ease}.ui-setup:not([open])>summary.ui-setup__head:after,:is(.ui-setup__step,.ui-setup__done):not([open])>.ui-setup__row:after{transform:rotate(-90deg)}.ui-setup__bar{appearance:none;display:block;width:100%;height:6px;border:0;border-radius:999px;overflow:hidden;background:var(--ui-border, #e5e7ea);color:var(--ui-setup-done)}.ui-setup__bar::-webkit-progress-bar{background:inherit;border-radius:inherit}.ui-setup__bar::-webkit-progress-value{background:currentColor;border-radius:inherit;transition:width .15s ease}.ui-setup__bar::-moz-progress-bar{background:currentColor;border-radius:inherit}.ui-setup__steps{display:grid;gap:2px}.ui-setup__mark{flex:0 0 18px;width:18px;height:18px;box-sizing:border-box;display:inline-grid;place-items:center;position:relative;border:1.5px solid color-mix(in srgb,var(--ui-text-muted, #686b71) 50%,transparent);border-radius:50%;font-size:0;color:var(--ui-text-muted, #686b71)}:is(.ui-setup__step[data-state=done],.ui-setup__done)>.ui-setup__row>.ui-setup__mark{background:var(--ui-setup-done);border-color:var(--ui-setup-done)}:is(.ui-setup__step[data-state=done],.ui-setup__done)>.ui-setup__row>.ui-setup__mark:before{content:"";position:absolute;left:5px;top:2px;width:4px;height:8px;box-sizing:content-box;border:solid #fff;border-width:0 1.75px 1.75px 0;transform:rotate(45deg)}.ui-setup__step[data-state=unknown]>.ui-setup__row>.ui-setup__mark{border-style:dashed;font-size:10px;font-weight:650}.ui-setup__label{min-width:0;overflow-wrap:anywhere;font-size:var(--ui-fs-body, 13px);line-height:20px;font-weight:550;color:var(--ui-text)}:is(.ui-setup__step[data-state=done],.ui-setup__done)>.ui-setup__row>.ui-setup__label,.ui-setup__optional .ui-setup__label{font-weight:450;color:var(--ui-text-muted)}.ui-setup__action{flex:none;display:inline-flex;gap:8px;margin:-4px 0 -4px auto}.ui-setup__actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.ui-setup__body{padding:4px 0 12px 30px;font-size:var(--ui-fs-body, 13px);line-height:20px;color:var(--ui-text)}.ui-setup__body>:first-child{margin-top:0}.ui-setup__body>:last-child{margin-bottom:0}.ui-setup__optional{display:grid;gap:8px;padding-top:12px;border-top:1px solid var(--ui-border, #e5e7ea)}.ui-setup__subtitle{margin:0;font-size:13px;line-height:20px;font-weight:650;color:var(--ui-text-muted)}.ui-setup__x{flex:none;display:inline-grid;place-items:center;width:28px;height:28px;margin:-4px -4px -4px auto;padding:0;border:0;border-radius:8px;background:none;color:var(--ui-text-muted);cursor:pointer}.ui-setup__x svg{width:16px;height:16px}@media (pointer: coarse),(max-width: 47.9975em){summary.ui-setup__head,.ui-setup__row{min-height:44px;box-sizing:border-box}.ui-setup__x{width:44px;height:44px;margin:-12px -12px -12px auto}:is(.ui-setup__body,.ui-setup__action) :is(button,a.ui-btn){min-height:44px}}@media (prefers-reduced-motion: reduce){.ui-setup__head,.ui-setup__row,summary.ui-setup__head:after,.ui-setup__row:after,.ui-setup__bar::-webkit-progress-value{transition:none}}
@@ -9,6 +9,14 @@
9
9
  * Why it moved here (08/10/2026): gemform's Telegram alerts page drew its own numbered steps (1 The bot, 2 Where alerts
10
10
  * go), the admin has this one in Polaris, and a third surface with a "connect this, then that" page would write a third.
11
11
  *
12
+ * ONE checklist for the fleet (21/10/2026, Chris: "cái onboarding checklist của gemcommunity với của gemboss nút tick
13
+ * màu xanh progress bar nên đồng bộ nhé để dùng cùng một gemboss ui kit"). GemBoss' "Set up your store" and
14
+ * GemCommunity's "Start here" were two drawings of one thing: grey ✓ glyphs and a black bar on one, filled green ticks,
15
+ * an iris bar and a "✓ 2 completed" fold on the other. Both now render this, so the guide took what each did better:
16
+ * the filled green tick (todo is an empty ring), a bar in the same green (it counts the ticks), the steps already done
17
+ * folded behind one "✓ N completed" row, an "Optional" group under the counted steps (the admin's notes), a step's own
18
+ * control at the end of its row (`action`), and a × for a guide that is dismissed rather than folded (`onDismiss`).
19
+ *
12
20
  * Native `<details>` all the way down, so a page that writes the markup by hand gets the same behaviour with no script:
13
21
  * the guide is a `<details>` whose `<summary>` folds it away, each step is a `<details name>` of one group, which the
14
22
  * browser keeps to one open at a time. The ROW reveals; what changes something is a button the surface puts in the
@@ -16,8 +24,10 @@
16
24
  * nothing.
17
25
  *
18
26
  * The step open at first is the first one not done, decided ONCE, like the admin: a step's state changing while the
19
- * person reads it must not slam it shut (finishing step 1 leaves its confirmation on screen). The bar is a <progress>,
20
- * not a width in a style attribute, so it fills under a CSP that forbids inline style.
27
+ * person reads it must not slam it shut (finishing step 1 leaves its confirmation on screen). The fold is decided once
28
+ * for the same reason: a step finished while the guide is on screen stays where it is and turns green, it does not
29
+ * vanish into the fold under the person's eyes; the next visit folds it. The bar is a <progress>, not a width in a
30
+ * style attribute, so it fills under a CSP that forbids inline style.
21
31
  */
22
32
  import "./setup-guide.css"; // its own entry (0.30.0)
23
33
  import React, { useEffect, useId, useRef, useState } from "react";
@@ -33,32 +43,57 @@ export interface SetupStep {
33
43
  state?: SetupState;
34
44
  /** What the open step shows: a sentence and the control that does it. Without one the row is not a toggle. */
35
45
  body?: React.ReactNode;
46
+ /** The control that does the step (21/10/2026): at the end of its row when the step has no body, under the body
47
+ * when it has one (a control inside the row would be a control inside a toggle). */
48
+ action?: React.ReactNode;
49
+ /** A class on the step, for the surface's own rule (GemCommunity hides its Telegram step where the rail asks). */
50
+ className?: string;
51
+ }
52
+
53
+ export interface SetupGuideLabels {
54
+ progress?: string;
55
+ notMeasured?: (n: number) => string;
56
+ /** The fold of the steps already done: "2 completed". */
57
+ completed?: (n: number) => string;
58
+ /** The heading of the `optional` group. */
59
+ optional?: string;
60
+ /** The × of a guide with `onDismiss`. */
61
+ dismiss?: string;
36
62
  }
37
63
 
38
64
  const MARK: Record<SetupState, string> = { done: "✓", todo: "○", unknown: "?" };
39
65
 
40
- export function SetupGuide({ title, description, steps, open, collapsible = true, rememberKey, group, labels, className }: {
66
+ export function SetupGuide({ title, description, steps, optional, open, collapsible = true, foldDone = true, onDismiss, rememberKey, group, labels, className }: {
41
67
  title: React.ReactNode;
42
68
  description?: React.ReactNode;
43
69
  steps: readonly SetupStep[];
70
+ /** Steps worth doing that the count leaves out (the admin's "Optional" notes): under a divider, never progress. */
71
+ optional?: readonly SetupStep[];
44
72
  /** The step to show open at first; defaults to the first one not done. */
45
73
  open?: string;
46
74
  /** false: the head is a plain heading, the guide cannot be folded away. */
47
75
  collapsible?: boolean;
76
+ /** Fold the steps done when the guide first renders behind one "✓ N completed" row (default). false lists them. */
77
+ foldDone?: boolean;
78
+ /** Put the guide away with a × instead of folding it (the surface remembers and stops rendering it). A guide that
79
+ * can be dismissed does not also fold: two ways to put one card away is one too many. */
80
+ onDismiss?: () => void;
48
81
  /** Remember a folded guide in localStorage under this key, so someone not setting up today is not asked again on every
49
82
  * visit. Give each place its own key (folding it on one page is not folding it everywhere). */
50
83
  rememberKey?: string;
51
84
  /** The steps' shared `name`. Defaults to a useId(), unique within one React root; pass one when two guides render in
52
85
  * separate roots or separate static renders. */
53
86
  group?: string;
54
- labels?: { progress?: string; notMeasured?: (n: number) => string };
87
+ labels?: SetupGuideLabels;
55
88
  className?: string;
56
89
  }) {
57
90
  const auto = useId();
58
91
  const name = group ?? `gb-setup${auto}`;
59
- // Decided once (see the header): later renders keep whatever the person opened.
92
+ // Decided once (see the header): later renders keep whatever the person opened, and what was folded.
60
93
  const [first] = useState(() => open ?? steps.find((s) => s.state !== "done" && s.body)?.id);
94
+ const [folded] = useState(() => new Set(foldDone ? steps.filter((s) => s.state === "done").map((s) => s.id) : []));
61
95
  const guide = useRef<HTMLDetailsElement>(null);
96
+ const folds = collapsible && !onDismiss;
62
97
  useEffect(() => {
63
98
  const el = guide.current;
64
99
  if (!el || !rememberKey) return undefined;
@@ -66,13 +101,32 @@ export function SetupGuide({ title, description, steps, open, collapsible = true
66
101
  const onToggle = () => { try { localStorage.setItem(rememberKey, el.open ? "open" : "folded"); } catch { /* ignore */ } };
67
102
  el.addEventListener("toggle", onToggle);
68
103
  return () => el.removeEventListener("toggle", onToggle);
69
- }, [rememberKey]);
104
+ }, [rememberKey, folds]);
70
105
 
71
106
  if (!steps.length) return null; // nothing to set up is no guide, not a "0/0" card
72
107
  const total = steps.length;
73
108
  const done = steps.filter((s) => s.state === "done").length;
74
109
  const unknown = steps.filter((s) => s.state === "unknown").length;
75
110
  const all = done === total;
111
+ // Folded = done when the guide first rendered AND still done (a step that came undone is back in the list).
112
+ const inFold = steps.filter((s) => folded.has(s.id) && s.state === "done");
113
+ const listed = steps.filter((s) => !inFold.includes(s));
114
+
115
+ const step = (s: SetupStep) => {
116
+ const state = s.state ?? "todo";
117
+ const row = (as: "summary" | "div") => React.createElement(as, { className: cx("ui-setup__row", as === "div" && "ui-setup__row--static") },
118
+ <span className="ui-setup__mark" aria-hidden="true">{MARK[state]}</span>,
119
+ <span className="ui-setup__label">{s.title}</span>,
120
+ as === "div" && s.action ? <span className="ui-setup__action">{s.action}</span> : null);
121
+ if (!s.body) return <div key={s.id} className={cx("ui-setup__step", s.className)} data-state={state}>{row("div")}</div>;
122
+ return (
123
+ <details key={s.id} className={cx("ui-setup__step", s.className)} name={name} open={s.id === first} data-state={state}>
124
+ {row("summary")}
125
+ <div className="ui-setup__body">{s.body}{s.action ? <div className="ui-setup__actions">{s.action}</div> : null}</div>
126
+ </details>
127
+ );
128
+ };
129
+
76
130
  const head = (
77
131
  <>
78
132
  <h2 className="ui-setup__title">{title}</h2>
@@ -85,27 +139,43 @@ export function SetupGuide({ title, description, steps, open, collapsible = true
85
139
  const inner = (
86
140
  <div className="ui-setup__inner">
87
141
  {description ? <p className="ui-setup__desc">{description}</p> : null}
88
- <progress className={cx("ui-setup__bar", all && "ui-setup__bar--done")} value={done} max={total}
142
+ <progress className="ui-setup__bar" value={done} max={total}
89
143
  aria-label={labels?.progress ?? "Setup progress"}>{`${done}/${total}`}</progress>
90
144
  <div className="ui-setup__steps">
91
- {steps.map((s) => {
92
- const state = s.state ?? "todo";
93
- const row = (as: "summary" | "div") => React.createElement(as, { className: cx("ui-setup__row", as === "div" && "ui-setup__row--static") },
94
- <span className="ui-setup__mark" aria-hidden="true">{MARK[state]}</span>,
95
- <span className="ui-setup__label">{s.title}</span>);
96
- if (!s.body) return <div key={s.id} className="ui-setup__step" data-state={state}>{row("div")}</div>;
97
- return (
98
- <details key={s.id} className="ui-setup__step" name={name} open={s.id === first} data-state={state}>
99
- {row("summary")}
100
- <div className="ui-setup__body">{s.body}</div>
101
- </details>
102
- );
103
- })}
145
+ {inFold.length ? (
146
+ // Not in the steps' `name` group: opening the fold must not shut the step that is open below it.
147
+ <details className="ui-setup__done">
148
+ <summary className="ui-setup__row">
149
+ <span className="ui-setup__mark" aria-hidden="true">{MARK.done}</span>
150
+ <span className="ui-setup__label">{labels?.completed?.(inFold.length) ?? `${inFold.length} completed`}</span>
151
+ </summary>
152
+ <div className="ui-setup__steps">{inFold.map(step)}</div>
153
+ </details>
154
+ ) : null}
155
+ {listed.map(step)}
104
156
  </div>
157
+ {optional?.length ? (
158
+ <div className="ui-setup__optional">
159
+ <h3 className="ui-setup__subtitle">{labels?.optional ?? "Optional"}</h3>
160
+ <div className="ui-setup__steps">{optional.map(step)}</div>
161
+ </div>
162
+ ) : null}
105
163
  </div>
106
164
  );
107
- if (!collapsible) {
108
- return <section className={cx("ui-card ui-setup", className)}><div className="ui-setup__head">{head}</div>{inner}</section>;
165
+ if (!folds) {
166
+ return (
167
+ <section className={cx("ui-card ui-setup", className)}>
168
+ <div className="ui-setup__head">
169
+ {head}
170
+ {onDismiss ? (
171
+ <button type="button" className="ui-setup__x" aria-label={labels?.dismiss ?? "Dismiss"} onClick={onDismiss}>
172
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" /></svg>
173
+ </button>
174
+ ) : null}
175
+ </div>
176
+ {inner}
177
+ </section>
178
+ );
109
179
  }
110
180
  return (
111
181
  <details className={cx("ui-card ui-setup", className)} open ref={guide}>