@xeonr/trellis-support-widget
v0.5.0
Published
The Xeonr lightbulb: a button and modal, in a shadow root, that sends feedback or a support request into a product's Trellis customer project and shows the person their own tickets and replies.
Readme
@xeonr/trellis-support-widget
The lightbulb every Xeonr product shows: a button and a modal where a person sends feedback or a support request into the product's Trellis customer project, attaches files or an annotated capture of the page, and reads and answers the team's replies under My tickets. It renders in a shadow root, so it looks the same on styled-components, Mantine/emotion and Tailwind hosts.
Token client (XMB, upl.im, …)
import { SupportProvider, SupportButton, SupportLauncher, useSupport } from "@xeonr/trellis-support-widget";
<SupportProvider config={{
product: "xmb",
version: import.meta.env.VITE_BUILD_SHA,
trellisUrl: "https://trellis.prod.wtf",
token: () => client.accessToken(), // the person's own bearer, with trellis:customer
reauth: () => signIn(), // a session without the scope signs in again
context: () => ({ mediaId }), // app.context.mediaId, shown to the person
}}>
<Nav><SupportButton /></Nav> // or <SupportLauncher /> in a corner
</SupportProvider>
const { open, unseen, copy } = useSupport();
open({ topic: "problem" }); // a "Report a problem" link
open({ tab: "tickets" });Support or feedback
A product that takes only feedback (XMB, PrintHub) passes mode: "feedback",
and nothing the widget shows says "support":
| | mode: "support" (default) | mode: "feedback" |
|---|---|---|
| lightbulb, tooltip, ⌘K | Contact support | Send feedback |
| modal title | Contact support | Share your feedback |
| tabs | New request · Your requests | New feedback · Your feedback |
| once sent | We read every one. Replies show up here under Your requests. | Thanks — we read every piece of feedback. Replies show up under Your feedback. |
copy overrides single strings over the preset, e.g.
copy: { title: "Talk to the XMB team" }. Every string is in SupportCopy
(button and launcher labels, tooltip, title, the ⌘K entry, tab names, topic
heading, body label and placeholder, submit, sending and success text, the
empty list, reply placeholders, the sign-in-again line); the presets are
exported as SUPPORT_COPY and FEEDBACK_COPY, and useSupport().copy is the
resolved set, for a host's own menu entry.
The unseen badge
The lightbulb (SupportButton, SupportLauncher) shows how many of the
person's tickets have news they haven't opened: a public reply from the team or
a change of state. The count is Trellis's (GetMyUnseenCount), so it is the
same on every device; opening a ticket under My tickets marks it seen. It shows
1–9, then "9+", nothing at 0, and the button's name says "(N updates)". The
widget refreshes it on mount, on window focus and when the tab becomes visible,
every five minutes while visible, and after a ticket is opened. A background
count never calls reauth: signed out, there is just no badge. In My tickets,
unseen rows are bold and the tab shows the count. useSupport().unseen is the
number.
Console
ConsoleShell support={{ product, version }} draws it in the top bar and ⌘K.
Without the shell, <ConsoleSupport config={{ product }}>; calls go
same-origin to bff's /api/support/ proxy, with no token in the browser.
Config
| | |
|---|---|
| product | the product slug intake is resolved for |
| version | app.version |
| trellisUrl + token / baseUrl | where calls go (default baseUrl: "/api/support") |
| reauth() | called when a call the person made fails unauthenticated or for want of the scope |
| context(), route(), redactUrl(url) | app.context.<name>, page.route, and a scrub for page.url |
| telemetry() | telemetry.session / telemetry.errors, e.g. over @lntrn/web (not imported) |
| screen | "dom" (default), "display" or "both": DOM snapshot, and/or "Screen or window…" (a getDisplayMedia frame) |
| theme | "auto" (default: follows the host page, see Theme), "light", "dark", or () => "light" \| "dark" \| undefined |
| brand | { primary, onPrimary?, vars? }: the product's colour over the violet, for a host without the kit (see Brand) |
| mode, copy | "support" (default) or "feedback" wording, and single strings over it (SupportCopy) |
| topic, myTickets, zIndex, fetch | preselected topic, the second tab and the badge (default on), stacking, a fetch to use |
| title | deprecated: copy.title |
The form is the project's: topics and fields come from GetIntake. The widget
fills the well-known keys a project asks for (page.url, page.title,
page.route, app.product, app.version, app.context.*, client.viewport,
client.ua, client.locale, client.timezone, client.theme,
telemetry.session, telemetry.errors) and shows them under "Also sending".
Mark anything that must never be in a page capture with
data-feedback-redact: it is covered before the snapshot is taken, in every
capture mode, so no preview ever shows it. The widget's own hosts are never in
a capture.
Theme
The modal and the lightbulb follow the host's theme, not the system's, so a
dark app gets a dark modal whatever the OS says. "auto" reads, in order:
data-theme="light|dark",data-mantine-color-scheme, or adark/light(theme-dark/theme-light) class on<html>, then on<body>;- the computed
color-schemeof<html>, then<body>, when it names one scheme (light darkandnormalname none); prefers-color-scheme, only when the page declares nothing.
A MutationObserver on <html> and <body> and a matchMedia listener apply
changes live, the modal open or not. A function is asked first, on every change
the widget sees and every render, and undefined falls back to the page; use
it when the host keeps its theme somewhere the page doesn't show. "light" /
"dark" fix it and beat everything. In a console, ConsoleShell passes the
kit's live theme, so its own toggle drives the widget.
Brand
The modal wears the host's brand, not only its light or dark: the accent, focus ring, tab underline, chips, primary button and the gradient stripe.
On a kit host (every console) nothing is configured. The widget's brand
tokens are var(--x-<name>, <kit default>), and custom properties inherit
into its shadow root, so a console whose brand.ts repointed --x-brand (a
green Functions, an amber Dev) gets that family in the modal, live. While the
widget's theme is the page's, it takes the page's surfaces, text and lines
too (data-follow on the hosts), so the modal matches the console's own
panels; a modal forced to the other scheme keeps its own.
Elsewhere, pass the product's primary and the widget derives the rest with
the kit's own brandRamp(), the same ramp a console of that colour gets:
<SupportProvider config={{ product: "printhub", brand: { primary: "#3b8cff" }, … }}>onPrimary (text and icons on the fill) defaults to white or near-black,
whichever reads on the gradient's palest stop; vars sets any --xf-*
property last. This replaces overriding raw --xf-* properties on the hosts,
which still works.
Never brand-coloured: the lightbulb's yellow, and the status tones (danger, success, warning), which are the kit's everywhere.
The lightbulb's colour
The lightbulb is lit-bulb yellow in both themes, from the kit's --x-idea
token (here --xf-idea; #ffd43b on dark, a gold stroke with a yellow glass
fill on light). Hover and the open state change the square behind it, not the
bulb. A host repaints it on the widget's host elements:
xeonr-support-button, xeonr-support-launcher {
--xf-bulb: #fff; /* the glyph */
--xf-bulb-glow: transparent; /* the glass fill */
}Any other --xf-* property set on the hosts (xeonr-support,
xeonr-support-button, xeonr-support-launcher) still overrides the token
sheet, as before.
Capture and annotate
"Capture this page" is a split button. The main half takes the visible
area (the viewport); the caret offers Region, Visible area, Full
page (the whole scrollable document) and, with screen: "both", Screen or
window…. Region steps the modal aside and dims the live page under a
crosshair: drag out a rectangle (its size shows; corners resize it, dragging
inside moves it), then Enter or Capture. Esc puts the modal back unchanged.
From the keyboard, the first arrow key drops a rectangle in the middle; arrows
move it, Shift+arrows resize it, Alt steps by 1px. Captures are at
devicePixelRatio (capped at 2), in page coordinates, so a scrolled page
captures what was selected.
The capture then opens in the annotator: Select (click to select, drag to move, corners and arrow ends resize, Delete removes, arrow keys nudge), Crop (drag, then Apply), Arrow, Box (an outline), Marker (a translucent freehand pen, simplified so it isn't a point per mousemove), Text (click, or Enter, to place a label and type it inline; double-click to edit; drag to move) and Black out, in four pen colours from the token sheet. One undo/redo history covers all of it, crops included (⌘Z, ⇧⌘Z, Ctrl+Y). Everything is burned into the PNG at the capture's scale, and a mark outside the crop is clipped with it.
@xeonr/trellis-support-widget/core is the same without React: the
CustomerService client, auto-fill and validation, capture and the
annotator's model.
0.5
- The modal follows the host's brand: on a kit host, its
--x-brandfamily (and, in the page's own scheme, its surfaces) by inheritance; elsewhere, the newbrand: { primary, onPrimary?, vars? }option, derived with the kit'sbrandRamp().SupportBrandis exported, and/corehasbrandVars. The lightbulb stays yellow (TRL-61).
0.4
- The lightbulb is yellow (
--xf-idea, from the kit's new--x-idea) in both themes, onSupportButton,SupportLauncherand the console top bar;--xf-bulband--xf-bulb-glowoverride it (TRL-61). theme: "auto"follows the host page (data-theme or a class on<html>/<body>, thencolor-scheme) before the system, live, and the hosts always carry the resolveddata-theme.themealso takes a function.ThemeSettingis exported.
0.3
- Capture modes chosen before capturing (Region, Visible area, Full page,
Screen or window…), the region picker, and the annotator's Select, Crop,
Arrow, Marker and Text tools, pen colours and undo/redo (TRL-55). The menu,
picker and annotator strings are in
SupportCopy. - Highlight is now a rectangle outline (it was a translucent fill).
/core:captureDom({ area }),regionToPixels, and the annotator's model (Mark,arrowHead,simplifyPath,layoutText,hitTest, …);renderAnnotatedtakes{ crop, unit }. The 0.2Annotationboxes are still valid marks.
0.2
modeandcopy(TRL-54); the support preset's wording is now "Contact support", with tabs "New request" / "Your requests".- The unseen badge, from Trellis (TRL-53). The localStorage last-seen tracking
is gone, with its
/coreexports (readSeen,markSeen,isUnread,unreadCount,seenKey).useSupport().unreadis now a deprecated alias ofunseen. TicketSummary.unseenandListMyTicketsResponse.unseenCountare new, andCustomerClienthasgetMyUnseenCount.
