@eeacms/volto-eea-chatbot 4.1.4 → 4.1.5

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/CHANGELOG.md CHANGED
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ ### [4.1.5](https://github.com/eea/volto-eea-chatbot/compare/4.1.4...4.1.5) - 13 October 2026
8
+
7
9
  ### [4.1.4](https://github.com/eea/volto-eea-chatbot/compare/4.1.3...4.1.4) - 12 October 2026
8
10
 
9
11
  #### :rocket: New Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-eea-chatbot",
3
- "version": "4.1.4",
3
+ "version": "4.1.5",
4
4
  "description": "@eeacms/volto-eea-chatbot: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -4,7 +4,7 @@ import { Button, Message, MessageContent } from 'semantic-ui-react';
4
4
  import { serializeNodes } from '@plone/volto-slate/editor/render';
5
5
  import Spinner from './Spinner';
6
6
  import SVGIcon from './Icon';
7
- import { getSupportedBgColor } from './markdown/colors';
7
+ import { getVerdictBgColor } from './markdown/colors';
8
8
 
9
9
  import GlassesIcon from '@eeacms/volto-eea-chatbot/icons/glasses.svg';
10
10
  import RotateIcon from '@eeacms/volto-eea-chatbot/icons/rotate.svg';
@@ -114,9 +114,13 @@ const HalloumiFeedback = ({
114
114
  color={scoreColor}
115
115
  className={cx(
116
116
  'claim-message',
117
+ // The summary chip wears the same three hues as the claims it
118
+ // summarises - the old eight-step red-to-green gradient implied a
119
+ // precision the score never had, skipped yellow entirely, and gave a
120
+ // colour-blind reader one ramp instead of three findings.
117
121
  emptyClaims
118
122
  ? 'claim-empty claim-gray-500'
119
- : getSupportedBgColor(score / 100, 'claim'),
123
+ : getVerdictBgColor(score / 100),
120
124
  )}
121
125
  icon
122
126
  >
@@ -38,9 +38,17 @@ export function ClaimModal({ claim, markers, text, citedSources }) {
38
38
 
39
39
  return (
40
40
  <Modal
41
- className={cx('claim-modal', getVerdictBgColor(claim.score))}
41
+ className={cx(
42
+ 'claim-modal',
43
+ getVerdictBgColor(claim.score, claim.context_limited),
44
+ )}
42
45
  trigger={
43
- <span className={cx('claim', getVerdictBgColor(claim.score))}>
46
+ <span
47
+ className={cx(
48
+ 'claim',
49
+ getVerdictBgColor(claim.score, claim.context_limited),
50
+ )}
51
+ >
44
52
  {text}
45
53
  </span>
46
54
  }
@@ -1,51 +1,32 @@
1
- export function getSupportedTextColor(score) {
2
- if (0 <= score && score < 0.5) {
3
- return 'text-red-500';
4
- } else if (0.5 <= score && score <= 1) {
5
- return 'text-green-500';
6
- }
7
- return 'text-gray-500';
8
- }
9
-
10
1
  /**
11
- * Colour for a single verified claim.
2
+ * Colour for a verdict: one per claim (`ClaimModal`) and one for the
3
+ * answer-level summary (`HalloumiFeedback`).
12
4
  *
13
5
  * A per-claim score is a verdict (supported 1.0, not_enough_info 0.4,
14
- * contradicted 0.0), not a measurement on a 0-1 scale, so the eight-step gradient
15
- * in `getSupportedBgColor` implied a confidence level that was never computed -
16
- * and it put `not_enough_info` in the red family, which reads as "the sources
17
- * contradict this" when they actually said nothing. Three bands on one green ladder
18
- * plus red: dark green for support, pale green for sources that said nothing, red for
19
- * a conflict. The two greens are far apart in lightness on purpose - adjacent greens
20
- * read as one colour.
6
+ * contradicted 0.0 - see `scoreToVerdict`), not a measurement on a 0-1 scale, so
7
+ * the eight-step red-to-green gradient that used to live in this file implied a
8
+ * confidence level that was never computed, and it put `not_enough_info` in the
9
+ * red family, which reads as "the sources contradict this" when they actually
10
+ * said nothing.
11
+ *
12
+ * Three verdicts, three hues: red for a conflict, yellow for silence, green for
13
+ * support. Hue alone is not enough - for red-green colour blindness the two ends
14
+ * of that ramp are the same colour - so each hue also gets its own underline
15
+ * style (see `.claim` in `ChatBlock/style.less`) and the modal names the verdict
16
+ * in words.
21
17
  *
22
- * The class names must exist in `colors.less`: the `claim-*` utilities come from an
23
- * explicit list of `.generate-colors()` calls, and an unlisted class renders unstyled,
24
- * silently. `colors.test.js` asserts the two stay in step.
18
+ * A claim whose sources were only partly loaded (`contextLimited`) is yellow
19
+ * whatever score it got: the header already refuses to name a verdict for it
20
+ * ("Couldn't be checked"), so colouring it green or red would claim more than the
21
+ * check established.
22
+ *
23
+ * The class names must exist in `colors.less`: the `claim-*` utilities come from
24
+ * an explicit list of `.generate-colors()` calls, and an unlisted class renders
25
+ * unstyled, silently. `colors.test.js` asserts the two stay in step.
25
26
  */
26
- export function getVerdictBgColor(score, prefix = 'claim') {
27
- if (score >= 0.8) return `${prefix}-green-700`;
28
- if (score >= 0.1) return `${prefix}-green-300`;
29
- return `${prefix}-red-500`;
30
- }
31
-
32
- export function getSupportedBgColor(score, prefix = 'bg') {
33
- if (0 <= score && score < 0.125) {
34
- return `${prefix}-red-500`;
35
- } else if (0.125 <= score && score < 0.25) {
36
- return `${prefix}-red-400`;
37
- } else if (0.25 <= score && score < 0.375) {
38
- return `${prefix}-red-300`;
39
- } else if (0.375 <= score && score < 0.5) {
40
- return `${prefix}-red-200`;
41
- } else if (0.5 <= score && score < 0.625) {
42
- return `${prefix}-green-200`;
43
- } else if (0.625 <= score && score < 0.75) {
44
- return `${prefix}-green-300`;
45
- } else if (0.75 <= score && score < 0.875) {
46
- return `${prefix}-green-400`;
47
- } else if (0.875 <= score && score <= 1) {
48
- return `${prefix}-green-500`;
49
- }
50
- return `${prefix}-gray-500`;
27
+ export function getVerdictBgColor(score, contextLimited = false) {
28
+ if (contextLimited) return 'claim-yellow-500';
29
+ if (score >= 0.8) return 'claim-green-500';
30
+ if (score >= 0.1) return 'claim-yellow-500';
31
+ return 'claim-red-500';
51
32
  }
@@ -16,7 +16,6 @@
16
16
  @green-400: #4ade80;
17
17
  @green-500: #22c55e;
18
18
  @green-100: #dcfce7; //
19
- @green-700: #166534; //
20
19
 
21
20
  @yellow-500: #eab308; //
22
21
  @yellow-400: #facc15; //
@@ -30,7 +29,7 @@
30
29
 
31
30
  @lime-500: #84cc16; //
32
31
 
33
- .generate-colors(@namespace, @name, @color, @lighter-value: 20, @darker-value: 20) {
32
+ .generate-colors(@namespace, @name, @color, @lighter-value: 20, @darker-value: 20, @stroke-value: 0) {
34
33
  @var-name: ~'--@{namespace}-color';
35
34
 
36
35
  .@{namespace}-@{name} {
@@ -38,24 +37,34 @@
38
37
  --@{namespace}-color: @color;
39
38
  --@{namespace}-lighter-color: lighten(@color, @lighter-value);
40
39
  --@{namespace}-darker-color: darken(@color, @darker-value);
40
+ // The colour for thin strokes - the claim underline, the verdict scale. A hue
41
+ // that is a fine fill is too light as a 2px line on white: yellow-500 is
42
+ // 1.92:1 there and green-500 2.28:1. Each call below darkens its stroke just
43
+ // enough to clear 3:1 (WCAG 1.4.11, non-text contrast) without moving the
44
+ // hue: red-500 3.76:1 untouched, green-500 -8 -> 3.32:1, yellow-500 -12 ->
45
+ // 3.37:1.
46
+ --@{namespace}-stroke-color: darken(@color, @stroke-value);
41
47
  }
42
48
  }
43
49
 
50
+ // Only the classes `getVerdictBgColor` emits are generated - an unlisted class
51
+ // renders unstyled, silently, with no colour and no error. `colors.test.js`
52
+ // asserts the two lists stay in step.
53
+ //
54
+ // The three verdicts get three hues, not a ladder: red = contradicted, yellow =
55
+ // not_enough_info, green = supported. Two greens were tried and failed - they
56
+ // read as one colour, and for red-green colour blindness red and green read as
57
+ // one colour too, which is why the underline *style* also changes per verdict
58
+ // (see `.claim` in `../style.less`).
59
+ //
60
+ // lighter/darker are set so the chip's text (`--claim-darker-color`) on the
61
+ // chip's background (`--claim-lighter-color`) clears WCAG AA: 5.51:1 red,
62
+ // 4.81:1 yellow, 5.55:1 green.
44
63
  .generate-colors(claim, gray-500, @gray-500, 45, 20);
45
64
  .generate-colors(claim, red-500, @red-500, 35, 20);
46
- .generate-colors(claim, red-400, @red-400, 25, 25);
47
- .generate-colors(claim, red-300, @red-300, 15, 25);
48
- .generate-colors(claim, red-200, @red-200, 8, 30);
49
- .generate-colors(claim, green-500, @green-500, 45, 20);
50
- .generate-colors(claim, green-400, @green-400, 35, 25);
51
- .generate-colors(claim, green-300, @green-300, 20, 48);
52
- .generate-colors(claim, green-200, @green-200, 10, 45);
53
-
54
- // The three claim verdicts are a green ladder plus red: green-700 = supported, the
55
- // darkest, green-300 = not confirmed by the sources, red-500 = contradicted. Kept
56
- // deliberately far apart in lightness - two adjacent greens read as one colour and the
57
- // distinction is lost. The darken value on green-300 is high (48) because it is the
58
- // chip's text colour on a pale background: at 35 the pair was 2.7:1, unreadable.
59
- // `getVerdictBgColor` emits these class names, and a test asserts every emitted class
60
- // is generated here.
61
- .generate-colors(claim, green-700, @green-700, 32, 8);
65
+ // not_enough_info: the sources said nothing. Not red - that means they said
66
+ // something else - and not gray, which is reserved for the no-verdict-at-all
67
+ // state. It sits alongside the yellow citation highlights, which are fills
68
+ // (@yellow-100/200) while a claim is a stroke, so the two do not read alike.
69
+ .generate-colors(claim, yellow-500, @yellow-500, 45, 20, 12);
70
+ .generate-colors(claim, green-500, @green-500, 45, 20, 8);
@@ -1,38 +1,68 @@
1
- import {
2
- getSupportedTextColor,
3
- getSupportedBgColor,
4
- getVerdictBgColor,
5
- } from './colors';
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { scoreToVerdict } from '../../utils';
4
+ import { getVerdictBgColor } from './colors';
5
+
6
+ const VERDICT_SCORES = [
7
+ ['supported', 1.0],
8
+ ['not_enough_info', 0.4],
9
+ ['contradicted', 0.0],
10
+ ];
11
+
12
+ const hueOf = (cls) => cls.match(/claim-([a-z]+)-/)[1];
6
13
 
7
14
  describe('getVerdictBgColor', () => {
8
15
  it('gives each verdict one colour band', () => {
9
- expect(getVerdictBgColor(1.0)).toBe('claim-green-700');
10
- expect(getVerdictBgColor(0.8)).toBe('claim-green-700');
11
- expect(getVerdictBgColor(0.4)).toBe('claim-green-300');
12
- expect(getVerdictBgColor(0.1)).toBe('claim-green-300');
16
+ expect(getVerdictBgColor(1.0)).toBe('claim-green-500');
17
+ expect(getVerdictBgColor(0.8)).toBe('claim-green-500');
18
+ expect(getVerdictBgColor(0.4)).toBe('claim-yellow-500');
19
+ expect(getVerdictBgColor(0.1)).toBe('claim-yellow-500');
13
20
  expect(getVerdictBgColor(0.0)).toBe('claim-red-500');
14
21
  });
15
22
 
23
+ it('uses three different hue families, one per verdict', () => {
24
+ // A reader who cannot separate red from green must still tell the verdicts
25
+ // apart, so no two of them may share a hue family. The green ladder this
26
+ // replaced (green-700 / green-300 / red-500) failed that: two of the three
27
+ // verdicts were "green".
28
+ const hues = VERDICT_SCORES.map(([, score]) =>
29
+ hueOf(getVerdictBgColor(score)),
30
+ );
31
+ expect(new Set(hues)).toEqual(new Set(['green', 'yellow', 'red']));
32
+ });
33
+
16
34
  it('keeps not_enough_info out of the red family', () => {
17
35
  // red reads as "the sources contradict this"; they said nothing
18
- expect(getVerdictBgColor(0.4)).not.toContain('red');
36
+ expect(hueOf(getVerdictBgColor(0.4))).not.toBe('red');
19
37
  });
20
38
 
21
- it('separates the two greens by more than one step', () => {
22
- // adjacent greens read as one colour, which would erase the distinction
23
- const step = (cls) => Number(cls.match(/green-(\d+)/)[1]);
24
- expect(
25
- step(getVerdictBgColor(1.0)) - step(getVerdictBgColor(0.4)),
26
- ).toBeGreaterThanOrEqual(300);
39
+ it('marks claims that could not be checked yellow, whatever the score', () => {
40
+ // a claim checked against only part of its sources gets no verdict in the
41
+ // header ("Couldn't be checked"), so the colour must not claim one either
42
+ expect(getVerdictBgColor(1.0, true)).toBe('claim-yellow-500');
43
+ expect(getVerdictBgColor(0.0, true)).toBe('claim-yellow-500');
44
+ });
45
+
46
+ it('colours verdicts exactly where scoreToVerdict names them', () => {
47
+ // the colour bands and the wording in the modal are computed separately from
48
+ // the same thresholds; if they drift, the chip says one thing and the header
49
+ // another
50
+ const hueForVerdict = {
51
+ 'claim-green-500': 'supported',
52
+ 'claim-yellow-500': 'not_enough_info',
53
+ 'claim-red-500': 'contradicted',
54
+ };
55
+ for (const score of [0, 0.05, 0.09, 0.1, 0.4, 0.79, 0.8, 1]) {
56
+ expect(hueForVerdict[getVerdictBgColor(score)]).toBe(
57
+ scoreToVerdict(score),
58
+ );
59
+ }
27
60
  });
28
61
 
29
62
  it('only emits classes that colors.less actually generates', () => {
30
63
  // an unlisted .generate-colors() call means the class renders unstyled, which
31
64
  // fails silently - no colour, no error
32
- const less = require('fs').readFileSync(
33
- require('path').join(__dirname, 'colors.less'),
34
- 'utf-8',
35
- );
65
+ const less = fs.readFileSync(path.join(__dirname, 'colors.less'), 'utf-8');
36
66
  for (const cls of [
37
67
  getVerdictBgColor(1.0),
38
68
  getVerdictBgColor(0.4),
@@ -43,72 +73,27 @@ describe('getVerdictBgColor', () => {
43
73
  );
44
74
  }
45
75
  });
46
- });
47
-
48
- describe('getSupportedTextColor', () => {
49
- it('should return "text-red-500" for scores between 0 and 0.5', () => {
50
- expect(getSupportedTextColor(0)).toBe('text-red-500');
51
- expect(getSupportedTextColor(0.4)).toBe('text-red-500');
52
- expect(getSupportedTextColor(0.499)).toBe('text-red-500');
53
- });
54
-
55
- it('should return "text-green-500" for scores between 0.5 and 1', () => {
56
- expect(getSupportedTextColor(0.5)).toBe('text-green-500');
57
- expect(getSupportedTextColor(0.75)).toBe('text-green-500');
58
- expect(getSupportedTextColor(1)).toBe('text-green-500');
59
- });
60
-
61
- it('should return "text-gray-500" for invalid scores', () => {
62
- expect(getSupportedTextColor(-1)).toBe('text-gray-500');
63
- expect(getSupportedTextColor(1.5)).toBe('text-gray-500');
64
- });
65
- });
66
76
 
67
- describe('getSupportedBgColor', () => {
68
- it('should return correct background colors based on score', () => {
69
- // Test for scores in the range [0, 0.125)
70
- expect(getSupportedBgColor(0)).toBe('bg-red-500');
71
- expect(getSupportedBgColor(0.1)).toBe('bg-red-500');
72
- expect(getSupportedBgColor(0.124)).toBe('bg-red-500');
73
-
74
- // Test for scores in the range [0.125, 0.25)
75
- expect(getSupportedBgColor(0.125)).toBe('bg-red-400');
76
- expect(getSupportedBgColor(0.2)).toBe('bg-red-400');
77
- expect(getSupportedBgColor(0.249)).toBe('bg-red-400');
78
-
79
- // Test for scores in the range [0.25, 0.375)
80
- expect(getSupportedBgColor(0.25)).toBe('bg-red-300');
81
- expect(getSupportedBgColor(0.3)).toBe('bg-red-300');
82
- expect(getSupportedBgColor(0.374)).toBe('bg-red-300');
83
-
84
- // Test for scores in the range [0.375, 0.5)
85
- expect(getSupportedBgColor(0.375)).toBe('bg-red-200');
86
- expect(getSupportedBgColor(0.45)).toBe('bg-red-200');
87
- expect(getSupportedBgColor(0.499)).toBe('bg-red-200');
88
-
89
- // Test for scores in the range [0.5, 0.625)
90
- expect(getSupportedBgColor(0.5)).toBe('bg-green-200');
91
- expect(getSupportedBgColor(0.55)).toBe('bg-green-200');
92
- expect(getSupportedBgColor(0.624)).toBe('bg-green-200');
93
-
94
- // Test for scores in the range [0.625, 0.75)
95
- expect(getSupportedBgColor(0.625)).toBe('bg-green-300');
96
- expect(getSupportedBgColor(0.7)).toBe('bg-green-300');
97
- expect(getSupportedBgColor(0.749)).toBe('bg-green-300');
98
-
99
- // Test for scores in the range [0.75, 0.875)
100
- expect(getSupportedBgColor(0.75)).toBe('bg-green-400');
101
- expect(getSupportedBgColor(0.8)).toBe('bg-green-400');
102
- expect(getSupportedBgColor(0.874)).toBe('bg-green-400');
103
-
104
- // Test for scores in the range [0.875, 1]
105
- expect(getSupportedBgColor(0.875)).toBe('bg-green-500');
106
- expect(getSupportedBgColor(0.9)).toBe('bg-green-500');
107
- expect(getSupportedBgColor(1)).toBe('bg-green-500');
108
- });
109
-
110
- it('should return "bg-gray-500" for invalid scores', () => {
111
- expect(getSupportedBgColor(-1)).toBe('bg-gray-500');
112
- expect(getSupportedBgColor(1.1)).toBe('bg-gray-500');
77
+ it('gives each verdict class its own underline style in style.less', () => {
78
+ // colour is not the only signal: each hue also has a line style (wavy /
79
+ // dotted / dashed). Renaming a class here without renaming the LESS selector
80
+ // would drop the shape distinction silently, so assert both lists agree.
81
+ const less = fs.readFileSync(
82
+ path.join(__dirname, '..', '..', 'style.less'),
83
+ 'utf-8',
84
+ );
85
+ const styles = {};
86
+ for (const cls of [
87
+ getVerdictBgColor(1.0),
88
+ getVerdictBgColor(0.4),
89
+ getVerdictBgColor(0.0),
90
+ ]) {
91
+ const rule = less.match(
92
+ new RegExp(`&\\.${cls} \\{\\s*text-decoration-style: ([a-z]+);`),
93
+ );
94
+ expect(rule).not.toBeNull();
95
+ styles[cls] = rule[1];
96
+ }
97
+ expect(new Set(Object.values(styles)).size).toBe(3);
113
98
  });
114
99
  });
@@ -693,13 +693,35 @@ mark {
693
693
  font-size: 14px;
694
694
  }
695
695
 
696
+ // The underline on a checked claim carries the verdict, and hue alone cannot do
697
+ // that job: for red-green colour blindness the good and the bad end of the ramp
698
+ // are the same colour. So hue and line style change together -
699
+ // contradicted ~~~~ wavy ~~~~ red
700
+ // not_enough_info ···· dotted ···· yellow
701
+ // supported ---- dashed ---- green
702
+ // The stroke uses `--claim-stroke-color` (see `components/markdown/colors.less`),
703
+ // a darkened variant of the hue: these hues are fine as large fills but a 2px
704
+ // line of yellow-500 is 1.92:1 against white and green-500 is 2.28:1.
696
705
  .claim {
697
706
  position: relative;
698
707
  display: inline;
699
708
  cursor: pointer;
700
- text-decoration: underline;
701
- text-decoration-color: var(--claim-color);
702
- text-decoration-style: wavy;
709
+ text-decoration-color: var(--claim-stroke-color);
710
+ text-decoration-line: underline;
711
+ text-decoration-thickness: 2px;
712
+ text-underline-offset: 0.2em;
713
+
714
+ &.claim-red-500 {
715
+ text-decoration-style: wavy;
716
+ }
717
+
718
+ &.claim-yellow-500 {
719
+ text-decoration-style: dotted;
720
+ }
721
+
722
+ &.claim-green-500 {
723
+ text-decoration-style: dashed;
724
+ }
703
725
 
704
726
  &:hover {
705
727
  background-color: var(--claim-lighter-color);
@@ -753,7 +775,7 @@ mark {
753
775
  padding: 1em 1.25em;
754
776
  border: none;
755
777
  border-radius: 0 @borderRadius @borderRadius 0;
756
- border-left: 4px solid var(--claim-color);
778
+ border-left: 4px solid var(--claim-stroke-color);
757
779
  margin: 0;
758
780
  background-color: @gray-50;
759
781
  color: @grey;
@@ -827,25 +849,29 @@ mark {
827
849
  flex: 1;
828
850
  border-radius: 3px;
829
851
  background-color: @gray-200;
830
- // An inset ring rather than a border: the green of `not_enough_info` is about
831
- // 2.8:1 against white, so without an outline the middle position - the one that
832
- // needs pointing out most - reads as empty. No layout shift, unlike a border.
852
+ // An inset ring rather than a border: the filled step is a thin bar of a hue
853
+ // that is under 3:1 against white (yellow-500 is 1.92:1), so without an
854
+ // outline the middle position - the one that needs pointing out most - reads
855
+ // as empty. No layout shift, unlike a border.
833
856
  box-shadow: inset 0 0 0 1px @gray-300;
834
857
  }
835
858
 
859
+ // The three hues match the claim underlines: red contradicted, yellow
860
+ // not_enough_info, green supported. Colour is decoration here - which step is
861
+ // filled is the finding, and it reads with no colour vision at all.
836
862
  .verdict-scale[data-verdict='contradicted'] .verdict-step:nth-child(1) {
837
863
  background-color: @red-500;
838
864
  }
839
865
 
840
- // Not red: sources saying nothing is not the same finding as sources saying
841
- // something else. green-500 rather than the chip's green-300 because a solid bar
842
- // on white needs more weight than a pale background does (green-300 is 1.7:1).
866
+ // Yellow, not red: sources saying nothing is not the same finding as sources
867
+ // saying something else. Grey is reserved for the `unknown` state below, where
868
+ // there is no verdict at all.
843
869
  .verdict-scale[data-verdict='not_enough_info'] .verdict-step:nth-child(2) {
844
- background-color: @green-500;
870
+ background-color: @yellow-500;
845
871
  }
846
872
 
847
873
  .verdict-scale[data-verdict='supported'] .verdict-step:nth-child(3) {
848
- background-color: @green-700;
874
+ background-color: @green-500;
849
875
  }
850
876
 
851
877
  // Checked over search snippets only: no position can be claimed.