visual-feedback 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/dist/{dialog-B57CJ4I_.mjs → dialog-W6m1knfu.mjs} +10 -2
- package/dist/index.mjs +1 -1
- package/dist/react.mjs +1 -1
- package/dist/{widget-B1JDOVI1.mjs → widget-DvGTo0B4.mjs} +215 -5
- package/docs/API.md +3 -3
- package/docs/SPEC.md +11 -5
- package/docs/THEMING.md +36 -12
- package/package.json +2 -2
package/AGENTS.md
CHANGED
|
@@ -74,8 +74,9 @@ your own words.
|
|
|
74
74
|
limit. The local folder works only in development: keep its dev server on localhost instead, since
|
|
75
75
|
it checks no user.
|
|
76
76
|
- **If the app has sign-in, call `discardDraft()` from `visual-feedback` at logout, with the same
|
|
77
|
-
`tagName` if one was set.** Why: a
|
|
78
|
-
across page loads
|
|
77
|
+
`tagName` if one was set.** Why: a draft, screenshots included, is kept in the tab across route
|
|
78
|
+
changes, and across page loads as it was last folded, and would come back for the next person
|
|
79
|
+
who signs in there.
|
|
79
80
|
- **For GitHub Issues, default to a separate private repo. Offer the code repo only when the
|
|
80
81
|
developer confirms it stays private forever.** Why: images cannot be fully erased from GitHub,
|
|
81
82
|
and if the code repo is made public later, every report in it is exposed.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-10-11)
|
|
4
|
+
|
|
5
|
+
- Breaking: with no `--vf-*` variables set, the widget now matches the site by itself. It takes
|
|
6
|
+
the page's accent (`theme-color`, else its coloured buttons, else its links), its button corners
|
|
7
|
+
(up to 15px) and, with `theme: "auto"`, its background and text colour, light or dark following
|
|
8
|
+
that background. A guess that does not contrast keeps the old default: the accent needs 3:1
|
|
9
|
+
against the widget's background and the text 4.5:1; the hints, the text at 60%, take more of it
|
|
10
|
+
where they would fall under 4.5:1. A site that wants the system colours back sets them, such as
|
|
11
|
+
`--vf-accent: AccentColor`; any `--vf-*` it sets still wins, and setting only one of
|
|
12
|
+
`--vf-surface` and `--vf-text` keeps the other at its default.
|
|
13
|
+
- The widget reads the page at load, at each open (a draft opened again from its bar too), when
|
|
14
|
+
`theme` or `css` changes, and when the page switches its theme: a change to the attributes of
|
|
15
|
+
`<html>` or `<body>` that changes the page's background, text colour or `color-scheme`, or a
|
|
16
|
+
change of the system's light or dark. The trigger, the dialog and the draft bar follow a site's
|
|
17
|
+
dark mode switch at once, or when the site's own colour fade ends. To see a switch it keeps one
|
|
18
|
+
mutation observer and one media query listener, both removed by `destroy()` (SPEC §9).
|
|
19
|
+
- The note shows 4 rows when the dialog opens, and the screenshot leaves room for them, so Send
|
|
20
|
+
stays in the window.
|
|
21
|
+
|
|
3
22
|
## 0.2.0 (2026-10-10)
|
|
4
23
|
|
|
5
24
|
- Breaking: a report holds `screenshots`, a list of up to 10, in place of `image` and `marks`.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A small button lets your users screenshot the page, mark it up and write a note.
|
|
|
4
4
|
report and store it where you want. It has no service, no tracking and no global CSS, and it fits
|
|
5
5
|
any design.
|
|
6
6
|
|
|
7
|
-

|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npm install visual-feedback
|
|
@@ -504,7 +504,7 @@ function icon(d, part) {
|
|
|
504
504
|
}
|
|
505
505
|
//#endregion
|
|
506
506
|
//#region src/dialog.ts
|
|
507
|
-
const LAYOUT = `.backdrop{position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:0;border:0;box-sizing:border-box;background:none;color:inherit;display:grid;place-items:center;overflow:auto}.backdrop:not([open]){display:none}.backdrop::backdrop{background:none}.dialog{box-sizing:border-box;width:min(560px,100% - 32px);display:grid;gap:12px}.dialog.wide{width:min(1100px,100% - 32px)}.header{display:flex;align-items:center;justify-content:space-between;gap:8px}.title{margin:0}.minimise{margin-left:auto}.body{display:grid;grid-template-columns:auto minmax(0,1fr);gap:12px;align-items:start}.body>.stage:only-child{grid-column:1/-1}.stage{position:relative;display:grid;gap:8px;min-width:0}.image{display:block;max-width:100%;max-height:min(60vh,100vh -
|
|
507
|
+
const LAYOUT = `.backdrop{position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:0;border:0;box-sizing:border-box;background:none;color:inherit;display:grid;place-items:center;overflow:auto}.backdrop:not([open]){display:none}.backdrop::backdrop{background:none}.dialog{box-sizing:border-box;width:min(560px,100% - 32px);display:grid;gap:12px}.dialog.wide{width:min(1100px,100% - 32px)}.header{display:flex;align-items:center;justify-content:space-between;gap:8px}.title{margin:0}.minimise{margin-left:auto}.body{display:grid;grid-template-columns:auto minmax(0,1fr);gap:12px;align-items:start}.body>.stage:only-child{grid-column:1/-1}.stage{position:relative;display:grid;gap:8px;min-width:0}.image{display:block;max-width:100%;max-height:min(60vh,100vh - 24.5em);margin:0 auto;color:var(--_mark)}.toolbar+.stage>.image{touch-action:none}.selection{position:absolute;pointer-events:none;box-sizing:border-box}.text-input{position:absolute;transform:translateY(-50%);min-width:6em;margin:0;box-sizing:content-box}.toolbar{display:flex;flex-direction:column;gap:4px}.dialog svg,.draft svg{width:1.15em;height:1.15em;flex:none;stroke-linecap:round}.divider{display:block}.strip{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:8px}.thumb{position:relative;display:flex}.thumb-pick{position:relative;display:block;margin:0;padding:0;line-height:0}.thumb-image{display:block;height:3em;width:auto}.thumb-number{position:absolute;left:4px;top:4px;line-height:1.2}.thumb-remove{position:absolute;top:-.5em;right:-.5em}.add,.capture-view{display:inline-flex;align-items:center;gap:6px}.composer{display:grid;gap:8px}.note{box-sizing:border-box;width:100%;min-height:64px;resize:vertical}.footer{display:flex;align-items:center;justify-content:space-between;gap:8px}.composer-bar,.actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px}.send{display:inline-flex;align-items:center;gap:6px}.split{position:relative;display:flex;margin-left:auto}.send-more{display:inline-flex;align-items:center}.menu{position:absolute;right:0;bottom:calc(100% + 6px);z-index:1;display:grid;min-width:max-content}.menu[hidden]{display:none}.save-pdf{display:inline-flex;align-items:center;gap:8px}.draft{position:fixed;z-index:var(--_z);box-sizing:border-box;max-width:calc(100% - 2 * var(--_off));display:flex;flex-wrap:wrap;align-items:center;gap:8px}.draft-thumbs{display:flex;gap:4px}.draft-thumbs .thumb-image{height:1.75em}.drafting>.trigger{display:none}.live{position:fixed;width:1px;height:1px;margin:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}`;
|
|
508
508
|
const THEME = `.backdrop{background:var(--_bd)}.dialog,.draft{background:var(--_s);color:var(--_t);border:1px solid var(--_b);border-radius:var(--_r);box-shadow:var(--_sh);padding:14px}.draft{padding:8px}.title{margin-left:4px;font-size:1.1em;font-weight:600}.header button,.toolbar button,.thumb-remove{font:inherit;width:2.15em;height:2.15em;padding:0;display:grid;place-items:center;border:0;border-radius:calc(var(--_r) / 2);background:none;color:var(--_m);cursor:pointer}.header button:hover,.toolbar button:hover:not(:disabled){background:color-mix(in srgb,var(--_t) 8%,transparent);color:var(--_t)}.body{padding:12px;border-radius:calc(var(--_r) * .75);background:color-mix(in srgb,var(--_t) 5%,var(--_s))}.toolbar{padding:4px;border:1px solid var(--_b);border-radius:calc(var(--_r) * .75);background:var(--_s)}.toolbar [aria-pressed=true]{background:color-mix(in srgb,var(--_a) 16%,transparent);color:var(--_a)}.toolbar button:disabled{opacity:.35;cursor:default}.divider{width:20px;margin:4px auto;border-top:1px solid var(--_b)}.image{border-radius:calc(var(--_r) / 2);box-shadow:0 0 0 1px var(--_b),0 2px 8px rgb(0 0 0/10%)}.toolbar+.stage>.image{cursor:crosshair}.selection{border:2px dashed var(--_f);border-radius:4px}.text-input{font-family:inherit;font-weight:600;line-height:1.2;color:var(--_mark);background:color-mix(in srgb,var(--_s) 85%,transparent);border:1px dashed var(--_mark);border-radius:4px;padding:0 4px;outline:none}.thumb-pick{border:2px solid transparent;border-radius:calc(var(--_r) / 2.5);background:none;overflow:hidden;cursor:pointer}.thumb-pick[aria-current=true]{border-color:var(--_a)}.thumb-image{border-radius:calc(var(--_r) / 4);box-shadow:0 0 0 1px var(--_b)}.thumb-number{font-size:.75em;font-weight:600;padding:0 4px;border-radius:4px;background:var(--_s);color:var(--_t)}.thumb-remove{width:1.5em;height:1.5em;border:1px solid var(--_b);border-radius:50%;background:var(--_s)}.label{font-size:.9em;font-weight:600;color:var(--_m)}.composer{padding:8px;border:1px solid var(--_b);border-radius:var(--_r);background:var(--_s);transition:border-color .15s,box-shadow .15s}.composer:focus-within{border-color:var(--_f);box-shadow:0 0 0 3px color-mix(in srgb,var(--_f) 22%,transparent)}.note{font:inherit;color:inherit;background:none;border:0;border-radius:calc(var(--_r) / 2);padding:4px 6px}.note:focus-visible{outline:none}.note::placeholder{color:var(--_m)}.category{font:inherit;color:inherit;background:color-mix(in srgb,var(--_t) 5%,var(--_s));border:1px solid var(--_b);border-radius:calc(var(--_r) / 2);padding:6px 8px}.send,.actions button,.add,.draft button{font:inherit;font-weight:600;height:2.25em;padding:0 .9em;border:1px solid var(--_b);border-radius:calc(var(--_r) / 1.6);background:var(--_s);color:inherit;cursor:pointer}.add{border-style:dashed;color:var(--_m)}.send,.draft .capture-view{background:var(--_a);color:var(--_at);border-color:var(--_a)}.split .send{border-radius:calc(var(--_r) / 1.6) 0 0 calc(var(--_r) / 1.6)}.send-more{font:inherit;height:2.25em;padding:0 .55em;border:1px solid var(--_a);border-left-color:color-mix(in srgb,var(--_at) 40%,var(--_a));border-radius:0 calc(var(--_r) / 1.6) calc(var(--_r) / 1.6) 0;background:var(--_a);color:var(--_at);cursor:pointer}.send-more:disabled{opacity:.6;cursor:progress}.send:where([data-ink=dark]),.send-more:where([data-ink=dark]),.draft .capture-view:where([data-ink=dark]){color:#000}.menu{padding:4px;border:1px solid var(--_b);border-radius:calc(var(--_r) / 1.6);background:var(--_s);color:var(--_t);box-shadow:var(--_sh)}.save-pdf{font:inherit;padding:.5em .8em;border:0;border-radius:calc(var(--_r) / 2.5);background:none;color:inherit;cursor:pointer;white-space:nowrap}.save-pdf:hover,.save-pdf:focus-visible{background:color-mix(in srgb,var(--_t) 8%,transparent)}.draft button:disabled{opacity:.5;cursor:default}.draft-label{margin:0 4px;font-size:.9em;font-weight:600}[aria-disabled=true]{opacity:.6;cursor:progress}.note:disabled,.category:disabled{opacity:.6}.hint{margin:0 4px;font-size:.85em;color:var(--_m)}.error{margin:0 4px;font-weight:500}.status{margin:8px 4px}`;
|
|
509
509
|
const ICONS = {
|
|
510
510
|
pen: "M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4zm9.5-13.5 4 4",
|
|
@@ -619,6 +619,7 @@ function thumbnail(image) {
|
|
|
619
619
|
}
|
|
620
620
|
function ink(button) {
|
|
621
621
|
const style = getComputedStyle(button);
|
|
622
|
+
delete button.dataset.ink;
|
|
622
623
|
if (style.getPropertyValue("--vf-accent-text").trim()) return;
|
|
623
624
|
let [r = 0, g = 0, b = 0] = /^rgba?\(([\d.]+), ([\d.]+), ([\d.]+)/.exec(style.backgroundColor)?.slice(1).map(Number) ?? [];
|
|
624
625
|
if (!/^rgb/.test(style.backgroundColor)) {
|
|
@@ -974,14 +975,19 @@ function mount(parent, handlers, settings = {}) {
|
|
|
974
975
|
function reopen() {
|
|
975
976
|
if (!minimised) return;
|
|
976
977
|
minimised = false;
|
|
978
|
+
handlers.restyle();
|
|
977
979
|
bar?.remove();
|
|
978
980
|
bar = void 0;
|
|
979
981
|
parent.append(backdrop);
|
|
980
982
|
backdrop.showModal();
|
|
981
983
|
lockPage(true);
|
|
982
984
|
renderStrip();
|
|
985
|
+
reink();
|
|
983
986
|
note?.focus();
|
|
984
987
|
}
|
|
988
|
+
function reink() {
|
|
989
|
+
for (const accent of parent.querySelectorAll(".send, .send-more, .capture-view")) ink(accent);
|
|
990
|
+
}
|
|
985
991
|
let seen;
|
|
986
992
|
let report;
|
|
987
993
|
let mounted = true;
|
|
@@ -1091,7 +1097,8 @@ function mount(parent, handlers, settings = {}) {
|
|
|
1091
1097
|
const field = element("textarea", "note", {
|
|
1092
1098
|
id: "vf-note",
|
|
1093
1099
|
placeholder: text.notePlaceholder,
|
|
1094
|
-
maxlength: String(NOTE_LIMIT)
|
|
1100
|
+
maxlength: String(NOTE_LIMIT),
|
|
1101
|
+
rows: "4"
|
|
1095
1102
|
});
|
|
1096
1103
|
note = field;
|
|
1097
1104
|
const required = element("p", "error", {
|
|
@@ -1312,6 +1319,7 @@ function mount(parent, handlers, settings = {}) {
|
|
|
1312
1319
|
lockPage(true);
|
|
1313
1320
|
},
|
|
1314
1321
|
reopen,
|
|
1322
|
+
reink,
|
|
1315
1323
|
async revive() {
|
|
1316
1324
|
reviving = true;
|
|
1317
1325
|
try {
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
|
|
2
|
-
export { t as createFeedback, n as discardDraft } from "./widget-
|
|
2
|
+
export { t as createFeedback, n as discardDraft } from "./widget-DvGTo0B4.mjs";
|
package/dist/react.mjs
CHANGED
|
@@ -1,4 +1,156 @@
|
|
|
1
1
|
|
|
2
|
+
//#region src/match.ts
|
|
3
|
+
const BUTTONS = "button,[role=\"button\"],input[type=\"submit\"],input[type=\"button\"]";
|
|
4
|
+
const SAMPLE = 200;
|
|
5
|
+
let pixel;
|
|
6
|
+
/** A colour as sRGB bytes and alpha; syntax other than rgb() (oklch, hex, names) goes through a canvas. */
|
|
7
|
+
function rgb(color) {
|
|
8
|
+
const match = /^rgba?\(([\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)(?:[ ,/]+([\d.]+)(%?))?\)/.exec(color);
|
|
9
|
+
if (match) {
|
|
10
|
+
const alpha = match[4] === void 0 ? 1 : Number(match[4]) / (match[5] ? 100 : 1);
|
|
11
|
+
return [
|
|
12
|
+
Number(match[1]),
|
|
13
|
+
Number(match[2]),
|
|
14
|
+
Number(match[3]),
|
|
15
|
+
alpha
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
if (pixel === void 0) pixel = Object.assign(document.createElement("canvas"), {
|
|
19
|
+
width: 1,
|
|
20
|
+
height: 1
|
|
21
|
+
}).getContext("2d", { willReadFrequently: true });
|
|
22
|
+
if (!pixel || !color) return void 0;
|
|
23
|
+
pixel.clearRect(0, 0, 1, 1);
|
|
24
|
+
pixel.fillStyle = "#0000";
|
|
25
|
+
pixel.fillStyle = color;
|
|
26
|
+
pixel.fillRect(0, 0, 1, 1);
|
|
27
|
+
const [r = 0, g = 0, b = 0, a = 0] = pixel.getImageData(0, 0, 1, 1).data;
|
|
28
|
+
return [
|
|
29
|
+
r,
|
|
30
|
+
g,
|
|
31
|
+
b,
|
|
32
|
+
a / 255
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
function luminance([r, g, b]) {
|
|
36
|
+
const linear = (c) => c / 255 <= .04045 ? c / 255 / 12.92 : ((c / 255 + .055) / 1.055) ** 2.4;
|
|
37
|
+
return .2126 * linear(r) + .7152 * linear(g) + .0722 * linear(b);
|
|
38
|
+
}
|
|
39
|
+
function contrast(a, b) {
|
|
40
|
+
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
41
|
+
return ((light ?? 0) + .05) / ((dark ?? 0) + .05);
|
|
42
|
+
}
|
|
43
|
+
const css = ([r, g, b]) => `rgb(${r} ${g} ${b})`;
|
|
44
|
+
const opaque = (color) => (color?.[3] ?? 0) > .99;
|
|
45
|
+
function vivid(color) {
|
|
46
|
+
if (!opaque(color)) return false;
|
|
47
|
+
const max = Math.max(color[0], color[1], color[2]);
|
|
48
|
+
const min = Math.min(color[0], color[1], color[2]);
|
|
49
|
+
const light = (max + min) / 510;
|
|
50
|
+
return (max - min) / 255 >= .2 && light > .1 && light < .9;
|
|
51
|
+
}
|
|
52
|
+
function visible(element) {
|
|
53
|
+
return element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden";
|
|
54
|
+
}
|
|
55
|
+
function commonest(colors) {
|
|
56
|
+
const counts = /* @__PURE__ */ new Map();
|
|
57
|
+
for (const color of colors) {
|
|
58
|
+
const entry = counts.get(css(color)) ?? [color, 0];
|
|
59
|
+
counts.set(css(color), [entry[0], entry[1] + 1]);
|
|
60
|
+
}
|
|
61
|
+
return [...counts.values()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
62
|
+
}
|
|
63
|
+
function themeColor() {
|
|
64
|
+
for (const meta of document.querySelectorAll("meta[name=\"theme-color\"]")) {
|
|
65
|
+
const media = meta.getAttribute("media");
|
|
66
|
+
if (!media || matchMedia(media).matches) return rgb(meta.content.trim());
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Takes the page's own accent, corners and, with `theme: "auto"`, background and text, where the
|
|
71
|
+
* host set no --vf-* for them. Each guess that fails contrast falls back to the defaults.
|
|
72
|
+
*/
|
|
73
|
+
function match(wrapper, theme) {
|
|
74
|
+
const style = wrapper.style;
|
|
75
|
+
for (const name of [
|
|
76
|
+
"--_pa",
|
|
77
|
+
"--_pm",
|
|
78
|
+
"--_pr",
|
|
79
|
+
"--_ps",
|
|
80
|
+
"--_pt"
|
|
81
|
+
]) style.setProperty(name, "initial");
|
|
82
|
+
style.colorScheme = theme?.replace("auto", "") ?? "";
|
|
83
|
+
if (!document.body || !wrapper.isConnected) return;
|
|
84
|
+
try {
|
|
85
|
+
guess(wrapper, theme, document.body);
|
|
86
|
+
} catch {}
|
|
87
|
+
}
|
|
88
|
+
function guess(wrapper, theme, body) {
|
|
89
|
+
const style = wrapper.style;
|
|
90
|
+
const own = getComputedStyle(wrapper);
|
|
91
|
+
const free = (variable) => !own.getPropertyValue(variable).trim();
|
|
92
|
+
if ((theme ?? "auto") === "auto" && free("--vf-surface") && free("--vf-text")) {
|
|
93
|
+
const background = [body, document.documentElement].map((element) => rgb(getComputedStyle(element).backgroundColor)).find(opaque);
|
|
94
|
+
const text = rgb(getComputedStyle(body).color);
|
|
95
|
+
if (background && opaque(text) && contrast(background, text) >= 4.5) {
|
|
96
|
+
style.setProperty("--_ps", css(background));
|
|
97
|
+
style.setProperty("--_pt", css(text));
|
|
98
|
+
const mixed = (share) => [
|
|
99
|
+
share * text[0] + (1 - share) * background[0],
|
|
100
|
+
share * text[1] + (1 - share) * background[1],
|
|
101
|
+
share * text[2] + (1 - share) * background[2],
|
|
102
|
+
1
|
|
103
|
+
];
|
|
104
|
+
let share = .6;
|
|
105
|
+
while (share < 1 && contrast(background, mixed(share)) < 4.5) share += .05;
|
|
106
|
+
if (share > .6) style.setProperty("--_pm", css(mixed(Math.min(1, share))));
|
|
107
|
+
style.colorScheme = luminance(background) > .179 ? "light" : "dark";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const browser = /* @__PURE__ */ new Map();
|
|
111
|
+
const plain = (scheme) => {
|
|
112
|
+
if (!browser.has(scheme)) {
|
|
113
|
+
const native = wrapper.appendChild(document.createElement("button"));
|
|
114
|
+
native.style.all = "revert";
|
|
115
|
+
native.style.colorScheme = scheme;
|
|
116
|
+
const look = getComputedStyle(native);
|
|
117
|
+
browser.set(scheme, `${look.backgroundColor} ${look.borderTopLeftRadius}`);
|
|
118
|
+
native.remove();
|
|
119
|
+
}
|
|
120
|
+
return browser.get(scheme);
|
|
121
|
+
};
|
|
122
|
+
const filled = [...document.querySelectorAll(BUTTONS)].slice(0, SAMPLE).filter(visible).map((button) => [button, getComputedStyle(button)]).filter(([, look]) => {
|
|
123
|
+
const face = `${look.backgroundColor} ${look.borderTopLeftRadius}`;
|
|
124
|
+
return face !== plain("light") && face !== plain("dark");
|
|
125
|
+
});
|
|
126
|
+
if (free("--vf-radius")) {
|
|
127
|
+
const shapes = filled.filter(([, look]) => opaque(rgb(look.backgroundColor))).map(([button, look]) => {
|
|
128
|
+
const radius = Number.parseFloat(look.borderTopLeftRadius) || 0;
|
|
129
|
+
return {
|
|
130
|
+
radius,
|
|
131
|
+
pill: radius * 2 >= button.getBoundingClientRect().height - 1
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
const corners = shapes.filter((shape) => !shape.pill).map((shape) => shape.radius);
|
|
135
|
+
corners.sort((a, b) => a - b);
|
|
136
|
+
const middle = corners[Math.floor((corners.length - 1) / 2)];
|
|
137
|
+
if (middle !== void 0 && corners.length * 2 > shapes.length) style.setProperty("--_pr", `${Math.min(24, middle * 1.6)}px`);
|
|
138
|
+
}
|
|
139
|
+
if (free("--vf-accent")) {
|
|
140
|
+
const links = () => [...document.querySelectorAll("a[href]")].slice(0, SAMPLE).filter(visible).map((link) => rgb(getComputedStyle(link).color)).filter(vivid);
|
|
141
|
+
const accent = [
|
|
142
|
+
themeColor(),
|
|
143
|
+
commonest(filled.map(([, look]) => rgb(look.backgroundColor)).filter(vivid)),
|
|
144
|
+
commonest(links())
|
|
145
|
+
].find(vivid);
|
|
146
|
+
const probe = wrapper.appendChild(document.createElement("i"));
|
|
147
|
+
probe.style.background = "var(--_s)";
|
|
148
|
+
const surface = rgb(getComputedStyle(probe).backgroundColor);
|
|
149
|
+
probe.remove();
|
|
150
|
+
if (accent && opaque(surface) && contrast(accent, surface) >= 3) style.setProperty("--_pa", css(accent));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
2
154
|
//#region src/widget.ts
|
|
3
155
|
const SVG = "http://www.w3.org/2000/svg";
|
|
4
156
|
function supported() {
|
|
@@ -10,9 +162,9 @@ function sheet(text) {
|
|
|
10
162
|
return result;
|
|
11
163
|
}
|
|
12
164
|
function variables() {
|
|
13
|
-
return `.vf{--_r:var(--vf-radius,16px);--_s:var(--vf-surface,Canvas);--_t:var(--vf-text,CanvasText);--_m:var(--vf-muted,color-mix(in srgb,var(--_t) 60%,transparent));--_b:var(--vf-border,color-mix(in srgb,var(--_t) 15%,transparent));--_a:var(--vf-accent,${CSS.supports("color", "AccentColor") ? "AccentColor" : "#0b57d0"});--_at:var(--vf-accent-text,#fff);--_f:var(--vf-focus,var(--_a));--_mark:var(--vf-mark,#e5243b);--_bd:var(--vf-backdrop,rgb(0 0 0/45%));--_sh:var(--vf-shadow,0 12px 32px -12px rgb(0 0 0/35%));--_size:var(--vf-trigger-size,2.5em);--_off:var(--vf-offset,16px);--_z:var(--vf-z,2147483000)}`;
|
|
165
|
+
return `.vf{--_r:var(--vf-radius,var(--_pr,16px));--_s:var(--vf-surface,var(--_ps,Canvas));--_t:var(--vf-text,var(--_pt,CanvasText));--_m:var(--vf-muted,var(--_pm,color-mix(in srgb,var(--_t) 60%,transparent)));--_b:var(--vf-border,color-mix(in srgb,var(--_t) 15%,transparent));--_a:var(--vf-accent,var(--_pa,${CSS.supports("color", "AccentColor") ? "AccentColor" : "#0b57d0"}));--_at:var(--vf-accent-text,#fff);--_f:var(--vf-focus,var(--_a));--_mark:var(--vf-mark,#e5243b);--_bd:var(--vf-backdrop,rgb(0 0 0/45%));--_sh:var(--vf-shadow,0 12px 32px -12px rgb(0 0 0/35%));--_size:var(--vf-trigger-size,2.5em);--_off:var(--vf-offset,16px);--_z:var(--vf-z,2147483000)}`;
|
|
14
166
|
}
|
|
15
|
-
const LAYOUT = `.vf{display:contents;font-family:var(--vf-font,inherit);color-scheme:light dark}.trigger{font:inherit;position:fixed;z-index:var(--_z);box-sizing:border-box;height:var(--_size);margin:0;padding:0 14px 0 12px;display:flex;align-items:center;gap:8px;white-space:nowrap}.trigger[data-icon]{width:var(--_size);padding:0;justify-content:center}[data-y=bottom]{bottom:calc(var(--_off) + env(safe-area-inset-bottom,0px))}[data-y=top]{top:calc(var(--_off) + env(safe-area-inset-top,0px))}[data-x=right]{right:calc(var(--_off) + env(safe-area-inset-right,0px))}[data-x=left]{left:calc(var(--_off) + env(safe-area-inset-left,0px))}.trigger svg{width:1.15em;height:1.15em}.capturing,.capturing .backdrop{visibility:hidden}:focus-visible{outline:2px solid var(--_f);outline-offset:2px}@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}`;
|
|
167
|
+
const LAYOUT = `.vf{display:contents;font-family:var(--vf-font,inherit);color-scheme:light dark}.trigger{font:inherit;position:fixed;z-index:var(--_z);box-sizing:border-box;height:var(--_size);margin:0;padding:0 14px 0 12px;display:flex;align-items:center;gap:8px;white-space:nowrap}.trigger[data-icon]{width:var(--_size);padding:0;justify-content:center}[data-y=bottom]{bottom:calc(var(--_off) + env(safe-area-inset-bottom,0px))}[data-y=top]{top:calc(var(--_off) + env(safe-area-inset-top,0px))}[data-x=right]{right:calc(var(--_off) + env(safe-area-inset-right,0px))}[data-x=left]{left:calc(var(--_off) + env(safe-area-inset-left,0px))}.trigger svg{width:1.15em;height:1.15em}.capturing,.capturing .backdrop{visibility:hidden}.still .trigger{transition:none!important}:focus-visible{outline:2px solid var(--_f);outline-offset:2px}@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}`;
|
|
16
168
|
const THEME = `.trigger{font-weight:600;border:1px solid var(--_b);border-radius:999px;background:var(--_s);color:var(--_t);box-shadow:var(--_sh);cursor:pointer;transition:background-color .15s}.trigger:hover{background:color-mix(in srgb,var(--_s) 90%,var(--_t))}`;
|
|
17
169
|
function icon() {
|
|
18
170
|
const svg = document.createElementNS(SVG, "svg");
|
|
@@ -39,6 +191,7 @@ function deepActive() {
|
|
|
39
191
|
return active;
|
|
40
192
|
}
|
|
41
193
|
const DISCONNECTED = Symbol.for("visual-feedback.disconnected");
|
|
194
|
+
const CONNECTED = Symbol.for("visual-feedback.connected");
|
|
42
195
|
const owners = /* @__PURE__ */ new Map();
|
|
43
196
|
const enders = /* @__PURE__ */ new WeakMap();
|
|
44
197
|
let dialogs;
|
|
@@ -74,6 +227,9 @@ function createFeedback(options = {}) {
|
|
|
74
227
|
if (!supported()) return noop;
|
|
75
228
|
const tag = options.tagName ?? "visual-feedback";
|
|
76
229
|
if (!customElements.get(tag)) customElements.define(tag, class extends HTMLElement {
|
|
230
|
+
connectedCallback() {
|
|
231
|
+
this[CONNECTED]?.();
|
|
232
|
+
}
|
|
77
233
|
disconnectedCallback() {
|
|
78
234
|
this[DISCONNECTED]?.();
|
|
79
235
|
}
|
|
@@ -137,11 +293,16 @@ function createFeedback(options = {}) {
|
|
|
137
293
|
...extra
|
|
138
294
|
];
|
|
139
295
|
}
|
|
296
|
+
let matched;
|
|
140
297
|
function apply() {
|
|
141
|
-
wrapper.style.colorScheme = options.theme?.replace("auto", "") ?? "";
|
|
142
298
|
base = [sheet(variables() + LAYOUT), ...options.unstyled ? [] : [sheet(THEME)]];
|
|
143
299
|
extra = options.css ? [sheet(options.css)] : [];
|
|
144
300
|
sheets();
|
|
301
|
+
if (`${options.theme} ${options.css}` !== matched) {
|
|
302
|
+
matched = `${options.theme} ${options.css}`;
|
|
303
|
+
match(wrapper, options.theme);
|
|
304
|
+
dialog?.reink();
|
|
305
|
+
}
|
|
145
306
|
if (options.trigger === "none") {
|
|
146
307
|
trigger?.remove();
|
|
147
308
|
trigger = void 0;
|
|
@@ -173,6 +334,50 @@ function createFeedback(options = {}) {
|
|
|
173
334
|
}
|
|
174
335
|
apply();
|
|
175
336
|
(options.container ?? document.body ?? document.documentElement).append(host);
|
|
337
|
+
let seen = "";
|
|
338
|
+
const look = () => [document.documentElement, document.body].map((element) => {
|
|
339
|
+
const style = element && getComputedStyle(element);
|
|
340
|
+
return style ? `${style.backgroundColor} ${style.color} ${style.colorScheme}` : "";
|
|
341
|
+
}).join();
|
|
342
|
+
function restyle() {
|
|
343
|
+
wrapper.classList.add("still");
|
|
344
|
+
match(wrapper, options.theme);
|
|
345
|
+
if (trigger) getComputedStyle(trigger).backgroundColor;
|
|
346
|
+
wrapper.classList.remove("still");
|
|
347
|
+
dialog?.reink();
|
|
348
|
+
seen = look();
|
|
349
|
+
}
|
|
350
|
+
restyle();
|
|
351
|
+
let missed = false;
|
|
352
|
+
function follow(always = false) {
|
|
353
|
+
missed ||= always;
|
|
354
|
+
if (!host.isConnected) return;
|
|
355
|
+
const read = () => {
|
|
356
|
+
if (!host.isConnected || !(missed || look() !== seen)) return;
|
|
357
|
+
missed = false;
|
|
358
|
+
restyle();
|
|
359
|
+
};
|
|
360
|
+
const fading = document.getAnimations().filter((animation) => animation instanceof CSSTransition).filter((animation) => /color|background/.test(animation.transitionProperty));
|
|
361
|
+
if (fading.length) Promise.allSettled(fading.map((fade) => fade.finished)).then(read);
|
|
362
|
+
else read();
|
|
363
|
+
}
|
|
364
|
+
const system = matchMedia("(prefers-color-scheme: dark)");
|
|
365
|
+
const switched = () => follow(true);
|
|
366
|
+
system.addEventListener("change", switched);
|
|
367
|
+
let watched = null;
|
|
368
|
+
const observer = new MutationObserver(() => {
|
|
369
|
+
if (document.body !== watched) {
|
|
370
|
+
watched = document.body;
|
|
371
|
+
if (watched) observer.observe(watched, { attributes: true });
|
|
372
|
+
}
|
|
373
|
+
follow();
|
|
374
|
+
});
|
|
375
|
+
observer.observe(document.documentElement, {
|
|
376
|
+
attributes: true,
|
|
377
|
+
childList: true
|
|
378
|
+
});
|
|
379
|
+
watched = document.body;
|
|
380
|
+
if (watched) observer.observe(watched, { attributes: true });
|
|
176
381
|
const keeps = () => {
|
|
177
382
|
if (options.draft === "memory") return false;
|
|
178
383
|
if (!owners.get(tag)?.isConnected && host.isConnected) owners.set(tag, host);
|
|
@@ -235,7 +440,7 @@ function createFeedback(options = {}) {
|
|
|
235
440
|
returnFocus = deepActive();
|
|
236
441
|
const stream = kept ? void 0 : requestDisplay();
|
|
237
442
|
try {
|
|
238
|
-
dialogs ??= import("./dialog-
|
|
443
|
+
dialogs ??= import("./dialog-W6m1knfu.mjs").catch((error) => {
|
|
239
444
|
dialogs = void 0;
|
|
240
445
|
throw error;
|
|
241
446
|
});
|
|
@@ -249,6 +454,7 @@ function createFeedback(options = {}) {
|
|
|
249
454
|
state = "idle";
|
|
250
455
|
return;
|
|
251
456
|
}
|
|
457
|
+
match(wrapper, options.theme);
|
|
252
458
|
dialog = view.mount(wrapper, {
|
|
253
459
|
close: () => widget.close(),
|
|
254
460
|
retry: () => void retry(capture).catch((error) => {
|
|
@@ -257,7 +463,8 @@ function createFeedback(options = {}) {
|
|
|
257
463
|
}),
|
|
258
464
|
emit,
|
|
259
465
|
options: () => options,
|
|
260
|
-
keeps
|
|
466
|
+
keeps,
|
|
467
|
+
restyle: () => match(wrapper, options.theme)
|
|
261
468
|
}, options);
|
|
262
469
|
const mounted = dialog;
|
|
263
470
|
if (!kept) mounted.capture(image);
|
|
@@ -315,6 +522,8 @@ function createFeedback(options = {}) {
|
|
|
315
522
|
state = "destroyed";
|
|
316
523
|
cancel();
|
|
317
524
|
unlisten?.();
|
|
525
|
+
observer.disconnect();
|
|
526
|
+
system.removeEventListener("change", switched);
|
|
318
527
|
const closers = wasOpen && !teardown(true) ? [...handlers.get("close") ?? []] : [];
|
|
319
528
|
handlers.clear();
|
|
320
529
|
host.remove();
|
|
@@ -346,6 +555,7 @@ function createFeedback(options = {}) {
|
|
|
346
555
|
if (wasOpen && !stays) emit("close");
|
|
347
556
|
}
|
|
348
557
|
enders.set(host, () => end());
|
|
558
|
+
host[CONNECTED] = () => follow();
|
|
349
559
|
host[DISCONNECTED] = () => queueMicrotask(() => {
|
|
350
560
|
if (!host.isConnected) end(true);
|
|
351
561
|
});
|
package/docs/API.md
CHANGED
|
@@ -39,7 +39,7 @@ const feedback = createFeedback({
|
|
|
39
39
|
| `headers` | `Record<string, string>` or `() => Record<string, string>` | – | Extra headers for `endpoint`. A function is called at Send; a throw shows the send error state, using the error's message. |
|
|
40
40
|
| `trigger` | `"button" \| "none"` | `"button"` | `"none"` draws no trigger; the host calls `open()`. A kept draft's bar still shows. |
|
|
41
41
|
| `position` | `"bottom-right" \| "bottom-left" \| "top-right" \| "top-left"` | `"bottom-right"` | Corner of the trigger. |
|
|
42
|
-
| `shortcut` | `string \| false` | `false` | For example `"Alt+Shift+F"`: any of `Alt`, `Shift`, `Control` and `Meta`, then one key, a letter, a digit or a key name such as `F2`, joined by `+`. The modifiers must match exactly, AltGr never matches, and any other modifier name makes it match nothing. Without `Control` or `Meta` it does not fire while focus is in a
|
|
42
|
+
| `shortcut` | `string \| false` | `false` | For example `"Alt+Shift+F"`: any of `Alt`, `Shift`, `Control` and `Meta`, then one key, a letter, a digit or a key name such as `F2`, joined by `+`. The modifiers must match exactly, AltGr never matches, and any other modifier name makes it match nothing. Without `Control` or `Meta` it does not fire while focus is in a form field (`input`, `textarea`, `select`) or editable content. It does nothing while the dialog is open or a draft is kept in the draft bar. Opt-in; it is the widget's only listener on `window` or `document`, one `keydown` on `window` (SPEC §9 names what else the widget watches, and what the capture library keeps). |
|
|
43
43
|
| `tools` | `("pen" \| "box" \| "arrow" \| "text" \| "select")[]` | all five | Order is the toolbar order, and the first drawing tool is the one selected at open. Repeats and unknown names are ignored; an empty list, or Select alone, shows no toolbar. The Delete button is there only with Select. |
|
|
44
44
|
| `categories` | `{ id: string; label: string }[]` | none | Shows a category picker. |
|
|
45
45
|
| `requireNote` | `boolean` | `true` | |
|
|
@@ -62,9 +62,9 @@ const feedback = createFeedback({
|
|
|
62
62
|
|---|---|
|
|
63
63
|
| `open(): Promise<void>` | Starts a capture. When a draft is kept in the draft bar, or this tab still keeps one, it opens the dialog on that draft instead, once it is back; when the dialog is open already, it does nothing. When the dialog cannot load or be shown, it rejects and `error` fires. |
|
|
64
64
|
| `close(): void` | Cancels and discards the draft, also when it is kept in the draft bar, and never asks first as the Cancel button can (SPEC §2); one a page load is still bringing back is only hidden, kept, with no event (SPEC §7). Before `submit` fires, nothing is sent; after it, the delivery is not stopped, and `sent` or `error` still fires when it ends. |
|
|
65
|
-
| `destroy(): void` | Removes the element, its listeners and its stylesheet, but not what the capture library keeps (SPEC §9). A delivery still under way fires neither `sent` nor `error` after it. The draft, open or folded, stays kept, so `close` does not fire: the next widget of the same tag made in this tab brings it back folded, as after a page load, so a route change keeps it. A host that removes the element gets the same
|
|
65
|
+
| `destroy(): void` | Removes the element, its listeners, its watch of the page's theme and its stylesheet, but not what the capture library keeps (SPEC §9). A delivery still under way fires neither `sent` nor `error` after it. The draft, open or folded, stays kept, so `close` does not fire: the next widget of the same tag made in this tab brings it back folded, as after a page load, so a route change keeps it. A host that removes the element gets the same for the draft, but its listeners and its watch of the page's theme stay until `destroy()`. A draft it cannot keep ends, and `close` fires as the last event: with `draft: "memory"`, a Send already handed to the host (`submit` fired), a widget that does not own its tag's draft, no screenshot (the capture error, or after "Send without screenshot"), a screenshot taken while it did not keep drafts, or sessionStorage that refuses it. To end the draft too, as at a logout, call `discardDraft()`. A draft whose IndexedDB write then fails (storage full) is lost, with no `close`: the widget is gone by then. |
|
|
66
66
|
| `on(event, fn): () => void` | Events: `open` (when a draft starts, and when a kept draft comes back), `close` (only when the draft ends: sent and closed, Cancel, Discard, `close()`, or a `destroy()` or removed element that does not keep it; folding into the draft bar, opening it again, and a `destroy()` or removal that keeps it fire none), `submit` (with the report, before each attempt), `sent` (with the report), `error` (with the error), `saved` (after Save as PDF downloads the PDF; nothing is sent). The type `FeedbackEvents` maps each event to what it passes. Returns an unsubscribe function. |
|
|
67
|
-
| `update(options): void` | Merges these options into the current ones, key by key: a key given as `undefined` goes back to its default, and `strings` or `capture` replaces the whole object. `tagName` and `container` are ignored. The trigger, `position` (a draft bar already shown moves at its next fold), `theme`, `css`, `unstyled`, `strings.trigger`, `strings.triggerText` and `shortcut` change at once. The dialog's texts, `tools`, `categories` and `requireNote` apply from the next open, and the rest at the next capture or send. |
|
|
67
|
+
| `update(options): void` | Merges these options into the current ones, key by key: a key given as `undefined` goes back to its default, and `strings` or `capture` replaces the whole object. `tagName` and `container` are ignored. The trigger, `position` (a draft bar already shown moves at its next fold), `theme`, `css`, `unstyled`, `strings.trigger`, `strings.triggerText` and `shortcut` change at once, and a new `theme` or `css` reads the page's look again (THEMING.md). The dialog's texts, `tools`, `categories` and `requireNote` apply from the next open, and the rest at the next capture or send. |
|
|
68
68
|
|
|
69
69
|
## discardDraft(tagName?: string): Promise<void>
|
|
70
70
|
Deletes the draft this tab keeps for a tag (default `"visual-feedback"`), as at a logout, so no
|
package/docs/SPEC.md
CHANGED
|
@@ -55,8 +55,8 @@ annotating, draft --Cancel (it may ask first, below) / Discard--> idle
|
|
|
55
55
|
speech control can say what it sees. It has no badge, and it does not move or pulse.
|
|
56
56
|
- It sits 16 px from the edges by default, and it respects `env(safe-area-inset-*)`.
|
|
57
57
|
- It is hidden while there is a draft, whether the dialog is open or kept in the draft bar.
|
|
58
|
-
- It does not
|
|
59
|
-
`--vf-offset`.
|
|
58
|
+
- It does not move to keep clear of the page's content. The host moves it off its own content
|
|
59
|
+
with `position` or `--vf-offset`.
|
|
60
60
|
|
|
61
61
|
## 4. Annotating
|
|
62
62
|
- **Tools:** Pen (freehand), Box (rectangle outline), Arrow and Text. Text places a label where the
|
|
@@ -148,13 +148,19 @@ Defined in `docs/API.md` (`FeedbackReport`). It holds:
|
|
|
148
148
|
- With the keyboard alone, the user can still write the note, choose a category and send without
|
|
149
149
|
marks.
|
|
150
150
|
- `prefers-reduced-motion` turns off every transition.
|
|
151
|
-
- `
|
|
151
|
+
- With `theme: "auto"`, the page's own background sets light or dark, and `prefers-color-scheme`
|
|
152
|
+
where the page shows none (THEMING.md).
|
|
152
153
|
|
|
153
154
|
## 9. Budgets
|
|
154
155
|
- **Loader** (the trigger and the `open()` wiring): 15 KB or less, minified and gzipped.
|
|
155
156
|
- **Annotator and dialog:** 135 KB or less, gzipped. This does not include the capture library.
|
|
156
157
|
- **Idle cost:** no work after the first paint of the trigger, past reading one sessionStorage
|
|
157
|
-
key
|
|
158
|
+
key and the page's colours and button corners at load, and reading them again when the page
|
|
159
|
+
switches its theme (THEMING.md section 1). To see a switch, it keeps one mutation observer on
|
|
160
|
+
the attributes of `<html>` and `<body>` and on the children of `<html>` (to find a new
|
|
161
|
+
`<body>`), and one `change` listener on `prefers-color-scheme`, both gone at `destroy()`. A
|
|
162
|
+
change the observer sees that leaves the page's colours alone reads the colours of `<html>` and
|
|
163
|
+
`<body>` and scans nothing else; a system switch always reads the page. Only when this tab kept a draft does the dialog load, to bring it back. After the first screenshot, the
|
|
158
164
|
capture library (snapdom) keeps 12 listeners on `window` and `document` and up to 5 mutation observers
|
|
159
165
|
until the page unloads, and offers no way to remove them; `destroy()` removes everything else
|
|
160
166
|
but a draft it keeps (§7). The opt-in `shortcut` adds one `keydown` listener on `window`, gone at
|
|
@@ -168,7 +174,7 @@ Chrome 111+, Firefox 113+ and Safari 16.4+. Older browsers get no trigger and no
|
|
|
168
174
|
## 11. Content Security Policy
|
|
169
175
|
- The widget's own CSS goes in through constructable stylesheets. It uses no `<style>` tags, and
|
|
170
176
|
sets the few inline styles it needs (the selection frame, a label being typed, the theme's
|
|
171
|
-
`color-scheme`) through the CSSOM, so a strict `style-src` does not block it. While the dialog is
|
|
177
|
+
`color-scheme` and the colours and corners it found on the page) through the CSSOM, so a strict `style-src` does not block it. While the dialog is
|
|
172
178
|
open, the scroll lock sets `overflow` on `<html>` through the CSSOM, which a CSP allows, and
|
|
173
179
|
puts back what was there on close.
|
|
174
180
|
- Capture (`@zumer/snapdom`) renders through `data:` image URLs, so a site with a CSP needs
|
package/docs/THEMING.md
CHANGED
|
@@ -3,11 +3,34 @@
|
|
|
3
3
|
The widget lives in one shadow root, so the site's CSS cannot break it, and its CSS cannot leak
|
|
4
4
|
out. There are four ways to adapt it, from lightest to heaviest:
|
|
5
5
|
|
|
6
|
-
## 1. It
|
|
7
|
-
Inside the shadow root, the widget sets `font: inherit
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
and
|
|
6
|
+
## 1. It matches the site
|
|
7
|
+
Inside the shadow root, the widget sets `font: inherit`, so a site rule such as
|
|
8
|
+
`* { all: unset }` cannot reset it. With no theming at all, it takes the site's font, and it
|
|
9
|
+
reads the page when it loads, at each open (a draft opened again from its bar too), when
|
|
10
|
+
`theme` or `css` changes, and when the page switches its theme: a change to the attributes of `<html>` or
|
|
11
|
+
`<body>`, such as a `dark` class, that changes the page's background, text colour or
|
|
12
|
+
`color-scheme`, or a change of the system's light or dark. The trigger, the dialog and the draft
|
|
13
|
+
bar then take the new look at once, or, where the site fades its colours, when the fade ends. A
|
|
14
|
+
widget whose element was off the page reads the page again when the element is put back. The rest
|
|
15
|
+
of the page is not watched, so a look changed only by a new stylesheet shows at the next open.
|
|
16
|
+
What it reads:
|
|
17
|
+
|
|
18
|
+
- **accent:** the page's `<meta name="theme-color">` (one with a `media` attribute counts only
|
|
19
|
+
where its media matches), else the most common coloured background
|
|
20
|
+
of its buttons, else the colour of its links. Greys, muted colours, near black, near white and
|
|
21
|
+
the browser's own unstyled buttons do not count.
|
|
22
|
+
- **corners:** the middle radius of the page's filled buttons, so the widget's buttons get the
|
|
23
|
+
same, up to 15px. A site where at least half of the filled buttons are pills keeps the
|
|
24
|
+
default.
|
|
25
|
+
- **background and text**, with `theme: "auto"`: the page's background and text colour, and light
|
|
26
|
+
or dark from that background.
|
|
27
|
+
|
|
28
|
+
A guess is used only when it contrasts: the accent 3:1 against the widget's background, the text
|
|
29
|
+
4.5:1 against it. Otherwise the default stays: the system colours `Canvas`, `CanvasText` and
|
|
30
|
+
`AccentColor`, light and dark from the system, and `16px` corners. With the page's text, the
|
|
31
|
+
widget's hints take more of it than 60% where 60% would fall under 4.5:1. Each `--vf-*` variable
|
|
32
|
+
the site sets wins over the guess for that variable; a site that sets only one of `--vf-surface`
|
|
33
|
+
and `--vf-text` gets the other from the defaults.
|
|
11
34
|
|
|
12
35
|
## 2. CSS custom properties
|
|
13
36
|
Set these on `visual-feedback` (or on any ancestor). Custom properties cross the shadow boundary.
|
|
@@ -15,12 +38,12 @@ Set these on `visual-feedback` (or on any ancestor). Custom properties cross the
|
|
|
15
38
|
| variable | default | used for |
|
|
16
39
|
|---|---|---|
|
|
17
40
|
| `--vf-font` | `inherit` | font family of all text, such as `Inter, sans-serif` |
|
|
18
|
-
| `--vf-radius` | `16px` | panel and composer, and a share of it for buttons and the image |
|
|
19
|
-
| `--vf-surface` | `Canvas` | panel, tool rail and composer background |
|
|
20
|
-
| `--vf-text` | `CanvasText` | text |
|
|
21
|
-
| `--vf-muted` | text at 60
|
|
41
|
+
| `--vf-radius` | from the page's button corners, so the widget's buttons match them up to 15px (this is 1.6 times theirs, up to `24px`), else `16px` | panel and composer, and a share of it for buttons and the image |
|
|
42
|
+
| `--vf-surface` | with `theme: "auto"` the page's background, else `Canvas` | panel, tool rail and composer background |
|
|
43
|
+
| `--vf-text` | with `theme: "auto"` the page's text colour, else `CanvasText` | text |
|
|
44
|
+
| `--vf-muted` | text at 60%, more on a page whose text would make that too faint | hints, counters |
|
|
22
45
|
| `--vf-border` | text at 15% | hairlines |
|
|
23
|
-
| `--vf-accent` | `AccentColor`, or `#0b57d0` where the browser has no `AccentColor` | Send button, active tool |
|
|
46
|
+
| `--vf-accent` | the page's accent, else `AccentColor`, or `#0b57d0` where the browser has no `AccentColor` | Send button and its menu button, "Capture this view", active tool, chosen screenshot |
|
|
24
47
|
| `--vf-accent-text` | black or white, whichever contrasts more with the accent | text on the accent |
|
|
25
48
|
| `--vf-focus` | `--vf-accent` | focus ring |
|
|
26
49
|
| `--vf-mark` | `#e5243b` | marks on the screenshot |
|
|
@@ -68,5 +91,6 @@ Class names used inside the shadow root are part of the public API from 1.0. Bef
|
|
|
68
91
|
change.
|
|
69
92
|
|
|
70
93
|
## Theme
|
|
71
|
-
`theme: "light" | "dark"` sets `color-scheme` inside the shadow root, which forces that scheme
|
|
72
|
-
`"auto"` follows the system
|
|
94
|
+
`theme: "light" | "dark"` sets `color-scheme` inside the shadow root, which forces that scheme
|
|
95
|
+
with the system colours. `"auto"` follows the page's background (section 1), and the system
|
|
96
|
+
where the page shows none.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "visual-feedback",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A small button lets your users screenshot the page, mark it up and write a note. You receive one report and store it where you want.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"feedback",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"test:pack": "playwright test -c tests/pack-smoke/playwright.config.ts",
|
|
61
61
|
"size": "size-limit",
|
|
62
62
|
"publint": "publint --strict",
|
|
63
|
-
"check": "
|
|
63
|
+
"check": "node tests/check.mjs",
|
|
64
64
|
"check:static": "npm run lint && npm run typecheck && npm run build && npm run test && npm run test:edge && npm run size && npm run publint",
|
|
65
65
|
"check:browser": "npm run test:e2e && npm run test:react && npm run test:pack",
|
|
66
66
|
"prepack": "npm run --silent build -- --logLevel warn",
|