diff --git a/mypolitics/spec/quiz/README.md b/mypolitics/spec/quiz/README.md index 268161c..64892e8 100644 --- a/mypolitics/spec/quiz/README.md +++ b/mypolitics/spec/quiz/README.md @@ -5,4 +5,36 @@ ## Contents | File | Description | |---|---| +| [Archetype](./archetype.md) | The named archetype, its match, its description and the ranking | +| [Double axis chart](./double-axis-chart.md) | Two opposing orientations on one bar | +| [Header](./header.md) | The top of the result: one orientation, its confidence and the tabs | +| [Horizontal bar chart](./horizontal-bar-chart.md) | A ranked list of orientations, flat or grouped | +| [Module wrapper](./module-wrapper.md) | The frame every result module sits in | +| [Multi axis chart](./multi-axis-chart.md) | Groups of axes, collapsed to their winners | +| [Nolan chart](./nolan-chart.md) | Two axes crossed into a map | +| [Single axis chart](./single-axis-chart.md) | One orientation, one bar | +| [Traits](./traits.md) | The traits a taker earned, as a set of pills | | [Universal axis](./universal-axis.md) | The bar every result module is built from | + +## Build order +The result modules lean on each other, so they are built in steps. Everything inside one step can be built at the same time; a step needs the ones above it only where the last column says so. + +| Step | Spec | Needs first | +|---|---|---| +| 0 | [Universal axis](./universal-axis.md) | Nothing | +| 0 | [Module wrapper](./module-wrapper.md) | Nothing | +| 1 | [Single axis chart](./single-axis-chart.md) | Universal axis, module wrapper | +| 1 | [Double axis chart](./double-axis-chart.md) | Universal axis, module wrapper | +| 1 | [Traits](./traits.md) | Module wrapper | +| 1 | [Header](./header.md) | Nothing | +| 1 | [Horizontal bar chart](./horizontal-bar-chart.md) | Universal axis, module wrapper | +| 2 | [Multi axis chart](./multi-axis-chart.md) | Double axis chart, for the lead rule | +| 2 | [Archetype](./archetype.md) | Header, for the match bands; horizontal bar chart, for the ranked row | +| 3 | [Nolan chart](./nolan-chart.md) | Multi axis chart, for the axis row | + +Four rules are written once and used by more than one module. Each lives in the spec that is built first: + +- **Lead rule** - which side of a pair wins, and when it is a tie. In the [double axis chart](./double-axis-chart.md). +- **Match bands** - no match, partial match, match. In the [header](./header.md). +- **Ranked row** - a name, a badge and a one-sided bar. In the [horizontal bar chart](./horizontal-bar-chart.md). +- **Axis row** - a heading naming an axis and its lead, over a double-sided bar. In the [multi axis chart](./multi-axis-chart.md). diff --git a/mypolitics/spec/quiz/archetype.md b/mypolitics/spec/quiz/archetype.md new file mode 100644 index 0000000..fc53d14 --- /dev/null +++ b/mypolitics/spec/quiz/archetype.md @@ -0,0 +1,148 @@ +# Archetype + +> Technical specification of the module that names the archetype a taker came out closest to. + +Docs: [Archetype](../../docs/modules/quiz/results/modules/archetype.md). | Design: [Figma](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-28187) + +![Archetype states, from no match to the full ranking](../../assets/archetype.png) + +## Kind +Front-end + +## Scope +One result module: a [module wrapper](./module-wrapper.md) around the leading archetype - its name, its match on a one-sided [universal axis](./universal-axis.md), and a description - with two controls that open the full description and the ranking of every other archetype. The module picks the leader from what it is given and remembers what the taker opened; it computes no score. + +Not covered here: + +- **How a bar draws a value or a comparison** - the [universal axis](./universal-axis.md) spec. +- **The three match bands** - the [header](./header.md) spec. +- **The row a ranking is made of** - the ranked row in the [horizontal bar chart](./horizontal-bar-chart.md) spec. +- **The card frame and its two buttons** - the [module wrapper](./module-wrapper.md) spec. +- **Whether an author's description is acceptable** - moderation. + +## Data +| Input | Data | Meaning | Rules | +|---|---|---|---| +| Title | Text | The card's name | Optional, as in the wrapper | +| Archetypes | A list of orientation, match, short description and full description | Every archetype the quiz scores | Descriptions are optional | +| Comparison | The other party, and their match per archetype | A friend's matches on the same bars | Optional | +| Statistics action, info action | Present or absent | The wrapper's two buttons | Optional, independent | + +An archetype is an orientation: a name, an image and a colour. A **match** is a number from 0 to 100. Names and both descriptions are author-supplied and untrusted. + +The **leader** is the archetype with the highest match. With equal matches, the first in the order given leads. + +## Interface +| Direction | Name | Shape | Notes | +|---|---|---|---| +| In | Everything in Data | - | Passed in by the result screen | +| Out | Statistics requested | An event with no payload | Passed through from the wrapper | +| Out | Info requested | An event with no payload | Passed through from the wrapper | + +Which view is open is the module's own state. + +## Behaviour + +### Leader +| Case | Behaviour | +|---|---| +| The leader's match is a match or a partial match | Its name, and a one-sided bar with its image on the cap | +| The leader's match is no match | The "no match" wording, quiet, in place of the name, and an empty track with no cap | +| The bar | No marker and no labels | +| The bar's colour | The colour of the match's band, never the archetype's own colour | + +### Views +The body under the leader shows one of three views. + +| View | Content | +|---|---| +| Summary | The leader's short description | +| Full description | The leader's full description | +| Ranking | Every other archetype as a ranked row, highest match first, each bar in the colour of its own band | + +The ranking is never cut: it shows every archetype it has. + +### Controls +Two controls sit at the foot of the card, description first, ranking second. + +| Case | Behaviour | +|---|---| +| Description control pressed | Opens the full description. Pressed again, returns to the summary | +| Ranking control pressed | Opens the ranking. Pressed again, returns to the summary | +| One view open and the other control pressed | Switches straight to the other view | +| The open view's control | Marked as active | +| Ranking control, closed | Previews the first few images from the ranking | +| Leader has no full description | No description control | +| No other archetypes | No ranking control | +| Neither control applies | No foot at all | + +### No match +| Case | Behaviour | +|---|---| +| Summary | No description. The module does not describe an archetype it declined to name | +| Description control | Not shown | +| Ranking | Shows every archetype, the leader included, since none of them is drawn above it | + +### Comparison +| Case | Behaviour | +|---|---| +| Comparison value for an archetype | Passed to that archetype's bar, on the leader and in the ranking | +| No comparison value for an archetype | That bar is drawn without an overlay | +| Comparison present | The leader and the order of the ranking do not change. The card stays the taker's | + +## States and lifecycle +| State | Condition | What is possible in it | +|---|---|---| +| Summary - "standard" | Nothing opened | Open the full description, open the ranking | +| Full description | Description control active | Return to the summary, switch to the ranking | +| Ranking | Ranking control active | Return to the summary, switch to the full description | +| No match | The leader is below 50 | Open the ranking | + +The module starts in the summary. The state lasts as long as the card is on screen and is not saved anywhere. + +## Rules and constraints +- **No match is a result.** The module never names the least bad option. +- **One leader.** Runners-up live in the ranking, however close they are. +- **Descriptions are plain text.** Paragraph breaks are kept; markup, links and anything else in the text are shown as written and never interpreted. +- **A band colours a bar, an orientation does not.** Every bar on this card takes its colour from its match's band, so the ranking reads as strong, loose and none at a glance. +- **The module never hides itself.** With no archetypes it is an empty card under its title. + +## Invalid and edge input +| Input | Behaviour | +|---|---| +| No archetypes | An empty card under its title | +| Match absent or not a number | Treated as zero, and listed last | +| Match outside 0-100 | Clamped | +| Short description missing, full description present | The summary has no text; the description control still opens the full one | +| Full description identical to the short one | No description control | +| Description that is only whitespace | Treated as missing | +| Archetype without an image | Its cap is drawn with colour only, and it is left out of the ranking control's preview | +| Archetype name longer than the row | Truncated; complete for assistive technology | +| Comparison value for an archetype not in the list | Ignored | + +Nothing here throws. + +## Non-functional + +### Rendering contexts +The result screen, comparison mode and the generated result image. In the image the caller passes no actions and the module is drawn in the summary, since nothing there can be opened. + +### Accessibility +- The leader's name is a heading inside the card. +- Each bar announces its archetype, match and comparison value, as the universal axis specifies. +- A band is never carried by colour alone: every bar has its number, and no match changes the wording. +- The two controls are buttons with text names, since each is drawn as an icon. Each says whether its view is open, and works from the keyboard. +- The ranking is an ordered list. +- When a view opens, focus stays on the control that opened it. + +## Dependencies +**Build order: step 2.** Needs the [universal axis](./universal-axis.md) and the [module wrapper](./module-wrapper.md), both built, and from step 1 the [header](./header.md) for the match bands and the [horizontal bar chart](./horizontal-bar-chart.md) for the ranked row. + +Relies on: + +- [Archetype doc](../../docs/modules/quiz/results/modules/archetype.md) - the idea this implements. +- [Universal axis](./universal-axis.md) and [module wrapper](./module-wrapper.md). +- [Header](./header.md) - the match bands. +- [Horizontal bar chart](./horizontal-bar-chart.md) - the ranked row. + +Relied on by nothing in this folder. The [short results card](../../docs/modules/quiz/results/short-results-card.md) reads the same leader and match. diff --git a/mypolitics/spec/quiz/double-axis-chart.md b/mypolitics/spec/quiz/double-axis-chart.md new file mode 100644 index 0000000..941761e --- /dev/null +++ b/mypolitics/spec/quiz/double-axis-chart.md @@ -0,0 +1,114 @@ +# Double axis chart + +> Technical specification of the module that shows two opposing orientations on one bar. + +Docs: [Double axis chart](../../docs/modules/quiz/results/modules/double-axis-chart.md). | Design: [Figma](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-2387) + +![Double axis states and examples](../../assets/double-axis-chart.png) + +## Kind +Front-end + +## Scope +One result module: a [module wrapper](./module-wrapper.md) whose title names the leading side and whose body is a single double-sided [universal axis](./universal-axis.md) with both poles labelled. It holds no state and computes no score. + +It also defines the **lead rule** - which side of a pair is the winner, and when there is none. The [multi axis chart](./multi-axis-chart.md) uses the same rule. + +Not covered here: + +- **How the bar draws two values, a marker or a comparison** - the [universal axis](./universal-axis.md) spec. +- **The card frame and its two buttons** - the [module wrapper](./module-wrapper.md) spec. +- **Which two orientations are paired** - quiz configuration. The pairing is the author's. + +## Data +| Input | Data | Meaning | Rules | +|---|---|---|---| +| Start entry | Orientation and value | The pole on the left cap | Required | +| End entry | Orientation and value | The pole on the right cap | Required | +| Marker | Position 0-100, or off | The reference line on the bar | Defaults to 50 | +| Comparison | Orientation and value | The other party on the same bar | Optional | +| Statistics action, info action | Present or absent | The wrapper's two buttons | Optional, independent | + +An orientation carries a name, an image and a colour, as in the universal axis. + +The two values are drawn as given. The module does not make them add up to a hundred: a pair of 47 and 31 leaves a gap in the middle of the bar. + +## Interface +| Direction | Name | Shape | Notes | +|---|---|---|---| +| In | Everything in Data | - | Passed in by the result screen | +| Out | Statistics requested | An event with no payload | Passed through from the wrapper | +| Out | Info requested | An event with no payload | Passed through from the wrapper | + +## Behaviour + +### Lead +The lead is decided on the values as the taker sees them - rounded to whole numbers - so the title can never contradict the bar. + +| Case | Lead | +|---|---| +| One displayed value is higher | That side | +| Both displayed values are equal, including both zero | None. It is a tie | +| One value absent | The side that has a value, if it is above zero | +| Both values absent | None | + +### Title +| Case | Behaviour | +|---|---| +| There is a lead | A chip with the leading orientation's icon and name, filled with its colour | +| Tie | A neutral chip naming both poles, start first, with no icon and no colour | +| Leading orientation without an image | The chip shows the name alone | + +The title names the side the taker landed on, not the axis. The chip is never interactive. + +### Bar +| Case | Behaviour | +|---|---| +| Both values present | A double-sided bar, each side filled from its own cap | +| Values that do not reach 100 together | The gap stays in the middle | +| One value absent | That side has no fill and no number; its cap and label still show | +| Both values absent | An empty double-sided track with both caps and labels | +| Comparison present | Passed to the bar unchanged | + +Labels are always on: each pole is named under its own cap. + +## Rules and constraints +- The module adds nothing to the bar's own rules. Value placement, hidden small values, the marker and the comparison band behave exactly as the universal axis specifies. +- A narrow lead is still a lead. 51 against 49 names a side; the marker is what shows how narrow it is. +- The module never hides itself. + +## Invalid and edge input +| Input | Behaviour | +|---|---| +| Value outside 0-100 | Clamped by the bar. The lead uses the clamped values | +| Values that exceed 100 together | Scaled by the bar. The lead uses the values as given | +| Value not a number | Treated as absent | +| Orientation name missing | Its label row stays reserved and empty. If it leads, the chip shows the image alone | +| Orientation names longer than the room | Each label is truncated by the bar, the title by the wrapper; both stay complete for assistive technology | +| Orientation without a colour | The neutral fallback, on the chip and on the bar | +| Both poles are the same orientation | Drawn as given. The module does not check the pairing | + +Nothing here throws. + +## Non-functional + +### Rendering contexts +The result screen, comparison mode and the generated result image. In the image the caller passes no actions. + +### Accessibility +- The card is named after the leading orientation, or after both poles on a tie, through the accessible name the wrapper takes for a component title. +- The bar announces both orientations, both values and the comparison value, as the universal axis specifies. +- The lead is never carried by colour alone: the title says it in words. + +## Dependencies +**Build order: step 1.** Needs the [universal axis](./universal-axis.md) and the [module wrapper](./module-wrapper.md), both built. It can be built alongside the [single axis chart](./single-axis-chart.md), [traits](./traits.md) and the [header](./header.md). + +Relies on: + +- [Double axis chart doc](../../docs/modules/quiz/results/modules/double-axis-chart.md) - the idea this implements. +- [Universal axis](./universal-axis.md) and [module wrapper](./module-wrapper.md). + +Relied on by: + +- [Multi axis chart](./multi-axis-chart.md) - uses the lead rule for every group, so this comes first. +- [Nolan chart](./nolan-chart.md) - opens into two double-sided bars. diff --git a/mypolitics/spec/quiz/header.md b/mypolitics/spec/quiz/header.md new file mode 100644 index 0000000..a869efb --- /dev/null +++ b/mypolitics/spec/quiz/header.md @@ -0,0 +1,125 @@ +# Header + +> Technical specification of the top of the result: one orientation, its confidence, and the two tabs. + +Docs: [Header](../../docs/modules/quiz/results/modules/header.md). | Design: [Figma](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-3239) + +![Header states](../../assets/header.png) + +## Kind +Front-end + +## Scope +The block at the top of the result screen: an avatar in a confidence ring, the orientation's name, the confidence, two optional author-written extras, and the tabs that switch between the taker's result and comparison. Unlike the other result modules it does not sit in a [module wrapper](./module-wrapper.md) and has no statistics or info button. + +It also defines the **match bands** - the three ranges a confidence or a match falls into. The [archetype](./archetype.md) uses the same bands. + +Not covered here: + +- **Which orientation leads and how confident the result is** - scoring. The header receives both. +- **What each tab shows** - the result screen, and [comparison modes](../../docs/modules/quiz/results/comparison/comparision-modes.md). +- **The identity block of the generated image** - the [short results card](../../docs/modules/quiz/results/short-results-card.md). +- **Whether an author's slogan or link is acceptable** - moderation. + +## Data +| Input | Data | Meaning | Rules | +|---|---|---|---| +| Orientation | Name and image | The orientation leading the group the author pointed the header at | Optional. Absent means no match | +| Confidence | Number, 0-100 | How strongly the answers support this result | Optional | +| Slogan | Text | An author-written line for this orientation | Optional | +| Link | A label and an address | Somewhere the author sends the taker | Optional | +| Active tab | "Your results" or "Comparison" | Which tab is selected | Required | + +Name, image, slogan and link are author-supplied and untrusted. + +### Match bands +| Band | Range | Meaning | +|---|---|---| +| No match | Below 50 | Nothing clears the bar | +| Partial match | From 50, below 80 | A result, held loosely | +| Match | 80 and above | A result | + +The bands are decided on the exact value, not the rounded one. Each band has its own colour, taken from the palette and never from the orientation. + +## Interface +| Direction | Name | Shape | Notes | +|---|---|---|---| +| In | Everything in Data | - | Passed in by the result screen | +| Out | Tab selected | The tab that was chosen | The header does not switch content itself | + +## Behaviour + +### Result +| Case | Behaviour | +|---|---| +| Match or partial match | The orientation's image inside a ring, its name, and the confidence written as a rounded percentage | +| Ring | An arc as long as the confidence, in the band's colour | +| Confidence text | In the band's colour | +| No match | A question mark in place of the image, no ring, the "no match" wording in place of the name, and no confidence | + +### Extras +| Case | Behaviour | +|---|---| +| Slogan present | A labelled chip. Not interactive | +| Link present | A button showing the label, which opens the address in a new tab | +| Neither | The row they share is not drawn | +| No match | Neither is drawn, whatever was passed. They belong to an orientation, and there is none | + +### Tabs +| Case | Behaviour | +|---|---| +| Always | Two tabs of equal width under the result: "Your results", then "Comparison" | +| Active tab | Marked as selected | +| A tab pressed | "Tab selected" is raised. The header waits for the new active tab to be passed in | + +### Layout +| Case | Behaviour | +|---|---| +| Narrow | Everything stacks: result, then extras, then tabs | +| Wide | The extras move to the end of the result's row, slogan above link. Tabs stay underneath, across the full width | + +The content is the same in both. Which layout applies depends on the width the header is given. + +## Rules and constraints +- **No match is an answer.** It is drawn as deliberately as a match, never as an error or an empty state. +- **The header never names the least bad option.** Below 50 the orientation's name and image are not shown, even though they were passed. +- **One orientation.** The header has no room for a runner-up or a split result. +- **Confidence is not a score.** It is always written with its word, never as a bare percentage that could be read as a match. + +## Invalid and edge input +| Input | Behaviour | +|---|---| +| Orientation absent | No match | +| Confidence absent or not a number | No match | +| Confidence outside 0-100 | Clamped | +| Orientation without an image | A neutral placeholder inside the ring | +| Name longer than the room | Wraps onto a second line, then is truncated; complete for assistive technology | +| Slogan or link label longer than the room | Truncated to one line; complete for assistive technology | +| Link with an empty label | The address is shown as the label | +| Link whose address is not a web address | The link is not drawn | +| Slogan with line breaks | Collapsed to one line | + +Nothing here throws. The slogan and the link are someone else's words in the product's most trusted spot, so a malformed one is dropped rather than shown broken. + +## Non-functional + +### Rendering contexts +The result screen only, at any width. + +### Accessibility +- The name is the main heading of the result screen. +- The ring is decorative. The confidence is carried by its text. +- A band is never carried by colour alone: no match changes the wording, and a partial match differs from a match by its number. +- The tabs are a tab list, operable from the keyboard, with the selected tab announced. +- The link says that it opens in a new tab. + +## Dependencies +**Build order: step 1.** Needs nothing in this folder. It can be built alongside the [single axis chart](./single-axis-chart.md), the [double axis chart](./double-axis-chart.md) and [traits](./traits.md). + +Relies on: + +- [Header doc](../../docs/modules/quiz/results/modules/header.md) - the idea this implements. + +Relied on by: + +- [Archetype](./archetype.md) - uses the match bands, so this comes first. diff --git a/mypolitics/spec/quiz/horizontal-bar-chart.md b/mypolitics/spec/quiz/horizontal-bar-chart.md new file mode 100644 index 0000000..95309b1 --- /dev/null +++ b/mypolitics/spec/quiz/horizontal-bar-chart.md @@ -0,0 +1,140 @@ +# Horizontal bar chart + +> Technical specification of the module that ranks orientations, best match first. + +Docs: [Horizontal bar chart](../../docs/modules/quiz/results/modules/horizontal-bar-chart.md). | Design: [Figma](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-1414) + +![Ranked lists, flat and grouped](../../assets/horizontal-bar-chart.png) + +## Kind +Front-end + +## Scope +One result module: a [module wrapper](./module-wrapper.md) around a ranked list of one-sided [universal axis](./universal-axis.md) bars, one per orientation. The list is either flat and cut short, or grouped by category. The module sorts what it is given and remembers what the taker opened; it computes no score. + +It also defines the **ranked row** - a name, an optional badge and a bar. The [archetype](./archetype.md) ranking is a list of the same rows. + +Not covered here: + +- **How a bar draws a value or a comparison** - the [universal axis](./universal-axis.md) spec. +- **The card frame and its two buttons** - the [module wrapper](./module-wrapper.md) spec. +- **Who gets a badge and what it says** - quiz configuration. The module draws the badge it is given. + +## Data +| Input | Data | Meaning | Rules | +|---|---|---|---| +| Title | Text | The card's name | Optional, as in the wrapper | +| Entries | A list of orientation, value and optional badge | What is ranked | Used when there are no categories | +| Categories | A list of a name and its entries | The same, broken by category | Optional. When present, the list is grouped | +| Visible rows | A whole number | How many rows a flat list shows before the fold | Defaults to 3. The author chooses where the fold is | +| Comparison | The other party, and their value per orientation | A friend's scores on the same rows | Optional | +| Statistics action, info action | Present or absent | The wrapper's two buttons | Optional, independent | + +A **badge** qualifies one row. It is an icon alone, or an icon with a short text, as in a verified mark or an "official" label. A row has at most one. + +## Interface +| Direction | Name | Shape | Notes | +|---|---|---|---| +| In | Everything in Data | - | Passed in by the result screen | +| Out | Statistics requested | An event with no payload | Passed through from the wrapper | +| Out | Info requested | An event with no payload | Passed through from the wrapper | + +What is open is the module's own state. Nothing outside is told about it. + +## Behaviour + +### Ranked row +| Case | Behaviour | +|---|---| +| A row | The orientation's name, then its badge if it has one, and under them a one-sided bar with the orientation's image on the cap | +| The bar | No marker and no labels. The name above already identifies it | +| Value absent | An empty track with no cap, as the universal axis draws "no orientation" | +| Comparison value for this orientation | Passed to the bar, which draws the band and the other party's image | +| No comparison value for this orientation | The row is drawn without an overlay | +| A row or a badge pressed | Nothing happens | + +### Order +| Case | Behaviour | +|---|---| +| Entries | Sorted by value, highest first | +| Equal values | Keep the order they were given in | +| Entries without a value | Last, in the order they were given | +| Comparison present | The order does not change. The list stays the taker's | + +### Flat list +| Case | Behaviour | +|---|---| +| More entries than visible rows | The top rows are shown, with a control under them that opens the rest | +| Opened | Every row is shown, and the control at the foot of the card closes the list again | +| As many entries as visible rows, or fewer | Every row is shown and there is no control | + +### Grouped list +| Case | Behaviour | +|---|---| +| A category | A heading with the category's name and its leading orientation, the leader's badge, the leader's bar, and a control that opens the category | +| The leader | The first row of the category's own ranking | +| Category where no entry has a value above zero | The heading says there is no result, the bar is an empty track, and the control still opens the category | +| The list of categories | Shown in the order given, never sorted and never cut | +| A category opened | The card shows that category alone: its heading and leader, then the rest of its ranking in full, and a control at the foot that returns to the list of categories | + +One category is open at a time, and the fold does not apply inside one: an opened category shows every row it has. + +## States and lifecycle +| State | Condition | What is possible in it | +|---|---|---| +| Flat, folded - "no categories - default" | No categories, more entries than visible rows, not opened | Open the rest | +| Flat, open - "no categories - expanded" | The same, opened | Close it | +| Flat, complete | No categories, nothing behind the fold | Read | +| Grouped - "group by category" | Categories present, none opened | Open one | +| Category open - "group by category - selected" | One category opened | Return to the list | + +The module starts folded, or grouped with nothing open. The state lasts as long as the card is on screen and is not saved anywhere. + +## Rules and constraints +- **Rank is by the taker's value only.** Nothing else moves a row - not a badge, not the comparison, not the category. +- **A badge is not a result.** It never changes a row's position, colour or bar. +- **The fold hides rows, it does not remove them.** Rows behind it are still in the ranking and still reachable in one press. +- **The module never hides itself.** With no entries it is an empty card under its title. + +## Invalid and edge input +| Input | Behaviour | +|---|---| +| Visible rows below 1, or not a whole number | The default of 3 | +| Both entries and categories passed | The categories win | +| Category with no entries | Drawn as a category with no result, and with no control to open it | +| Category without a name | The heading shows the leader alone | +| Entry without an orientation name | The bar is drawn under an empty name | +| Name and badge longer than the row | The name is truncated first and stays complete for assistive technology; the badge keeps its text | +| Badge with a text but no icon, or an icon but no text | Drawn with what it has | +| Value outside 0-100, or not a number | Clamped, or treated as absent, as the universal axis specifies | +| The same orientation twice in one list | Drawn twice. The module does not deduplicate | +| Comparison value for an orientation not in the list | Ignored | + +Nothing here throws. + +## Non-functional + +### Rendering contexts +The result screen, comparison mode and the generated result image. In the image the caller passes no actions and the module is drawn in its starting state, since nothing there can be opened. + +### Accessibility +- The rows are an ordered list, so the rank is announced and not only seen. +- Each bar announces its orientation, value and comparison value, as the universal axis specifies. +- A badge is announced with its row. An icon-only badge has a text name. +- The open and close controls are buttons that say what they do and whether the list is open, and work from the keyboard. +- When a category opens or closes, focus stays on the control that was pressed or moves to the one that replaces it. It is never lost. + +### Performance +An opened list can hold dozens of bars. The module does no measuring; opening and closing only changes which rows are drawn. + +## Dependencies +**Build order: step 1.** Needs the [universal axis](./universal-axis.md) and the [module wrapper](./module-wrapper.md), both built. It can be built alongside the other step 1 modules, and the [archetype](./archetype.md) waits on it. + +Relies on: + +- [Horizontal bar chart doc](../../docs/modules/quiz/results/modules/horizontal-bar-chart.md) - the idea this implements. +- [Universal axis](./universal-axis.md) and [module wrapper](./module-wrapper.md). + +Relied on by: + +- [Archetype](./archetype.md) - its ranking is a list of ranked rows, so this comes first. diff --git a/mypolitics/spec/quiz/module-wrapper.md b/mypolitics/spec/quiz/module-wrapper.md new file mode 100644 index 0000000..3b9f720 --- /dev/null +++ b/mypolitics/spec/quiz/module-wrapper.md @@ -0,0 +1,138 @@ +# Module wrapper + +> Technical specification of the frame every result module sits in. + +Docs: [Module wrapper](../../docs/modules/quiz/results/modules/module-wrapper.md). | Design: [Figma](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-3483) + +![The four wrapper configurations](../../assets/module-wrapper.png) + +## Kind +Front-end + +## Scope +One presentational card. It draws a header - a title and up to two action buttons - a divider, and a body it knows nothing about. It holds no state, fetches nothing and decides nothing about the result. Every result module is this card around its own content: single axis, double axis, multi axis, horizontal bar, Nolan chart, archetype and traits. + +What it does not cover, and where that lives: + +- **What the two buttons open** - the statistics and the explanation are their own functionalities, see [module statistics](../../docs/modules/quiz/results/module-statistics.md) and [info modals](../../docs/modules/quiz/results/info-modals.md). The wrapper only reports that a button was pressed. +- **What goes in the body** - each module's own doc and spec, built from the [universal axis](./universal-axis.md). +- **Whether a module is shown at all** - the result screen decides. A module that is not configured is never handed to the wrapper, see [short results card](../../docs/modules/quiz/results/short-results-card.md). +- **What happens when a module fails to draw** - containment belongs to the result screen, which is the one that can decide what replaces a broken module. +- **Checkpoint cards** - they borrow a module's visual, not this frame. + +## Data +| Input | Data | Meaning | Rules | +|---|---|---|---| +| Title | Text, or a component | What the card is called | Optional. Text is the usual case; a component replaces it entirely | +| Statistics action | Present or absent | Whether the card offers the module's statistics | Optional, independent of the info action | +| Info action | Present or absent | Whether the card offers the module's explanation | Optional, independent of the statistics action | +| Body | Any content | What the module draws | Optional as far as the wrapper is concerned | + +A **text title** is the module name, written by the quiz author. It is author-supplied, so it has no length limit and can be empty. + +A **component title** is anything a module puts in the title's place. The [Nolan chart](../../docs/modules/quiz/results/modules/nolan-chart.md) puts its coloured quadrant name there, the [archetype](../../docs/modules/quiz/results/modules/archetype.md) puts its result. It may be interactive; what it does is specified by the module that supplies it. + +An **action** is one of the two header buttons. The wrapper does not know what is behind either of them. + +## Interface +| Direction | Name | Shape | Notes | +|---|---|---|---| +| In | Title | Text or a component | Drawn in the title slot | +| In | Body | Any content | Drawn under the divider | +| In | Accessible name | Text | Needed only with a component title, which the wrapper cannot read a name from | +| Out | Statistics requested | An event with no payload | Raised when the statistics button is pressed | +| Out | Info requested | An event with no payload | Raised when the info button is pressed | + +An action exists exactly when something is listening for it. There is no separate switch to show a button, so a button that does nothing cannot be drawn. + +## Behaviour + +### Title +| Case | Behaviour | +|---|---| +| Text title | Drawn in the title pill, centred, on one line | +| Text title longer than the slot | Truncated visually, complete for assistive technology | +| Component title | Replaces the pill. The wrapper adds no border, background or text styling of its own | +| Component title larger than the slot | Clipped to the slot. It never grows the header or pushes an action out | +| Text title pressed | Nothing happens. A text title is not interactive | + +### Actions +| Case | Behaviour | +|---|---| +| Both present | Statistics first, info last, at the end of the header | +| Statistics only | One button, at the end of the header | +| Info only | One button, at the end of the header | +| Neither | No buttons | +| Button pressed | The matching event is raised once per press. Nothing else on the card changes | + +Each button keeps its own icon and its own position relative to the other. A missing button leaves no gap: the title slot takes the room. + +### Layout +| Case | Behaviour | +|---|---| +| Header | Title slot first, actions after it, on one row of fixed height | +| Title slot width | Whatever the actions leave. It is widest with no actions and narrowest with two | +| Divider | Drawn across the full card width between header and body | +| Card width | The width the card is given | +| Card height | The header plus whatever the body needs. There is no minimum and no maximum | + +## States and lifecycle +The card has no state of its own. What it renders is fully decided by which inputs are present. Figma draws four configurations: + +| State | Condition | What is possible in it | +|---|---|---| +| Titled card - "no actions nor stats/info" | Text title, no actions | Read the body | +| Text title with both actions - "without actions" | Text title, statistics and info | Read the body, open statistics, open the explanation | +| Component title with both actions - "with action" | Component title, statistics and info | The same, plus whatever the title component offers | +| Component title with statistics - "with action, w/o info" | Component title, statistics only | Read the body, open statistics, plus whatever the title component offers | + +The title kind and each action are independent, so four more configurations exist that Figma does not draw: a text title with one action, a component title with info only, and a component title with no actions. They follow the same layout rule and need no design of their own. + +## Rules and constraints +- **The two buttons are the only way out of a module.** The wrapper adds no other control - no menu, no close, no collapse, no link on the card itself. +- **An action is present or absent, never disabled.** There is no disabled, loading or badge state for either button. A module whose statistics are not available yet shows no statistics button. +- **The wrapper never looks inside the body.** It does not measure it, scroll it, clip it to a height or react to what it contains. +- **The wrapper never hides itself.** With an empty body it still draws its header. Leaving a module out is the result screen's decision. +- **One size.** There is no compact variant. The card keeps its padding and header height at every width, as the [universal axis](./universal-axis.md) does, so a module looks the same wherever it is drawn. +- **The frame does not change per module.** A module cannot restyle the card, move the actions or remove the divider. The title slot is the only part it controls, which is the price of every module looking like the others. + +## Invalid and edge input +| Input | Behaviour | +|---|---| +| Title missing, empty or only whitespace | Treated as no title | +| No title and no actions | The header and the divider are not drawn. The card is its body alone | +| No title, with actions | The header is drawn with the actions in their usual place and an empty title slot | +| Title text with line breaks | Collapsed to one line | +| Body missing or empty | Header and divider are drawn, nothing under them | +| No title, no actions and no body | An empty card | +| Body wider than the card | The body is given the card's inner width. Content that does not fit is the module's to solve; the wrapper never scrolls sideways | + +Nothing here throws. Titles are author-supplied and can be wrong; a card with a bad title must still show its result. + +## Non-functional + +### Rendering contexts +The card renders on the result screen and in comparison mode. Where a result is drawn without interaction, such as the generated image of the [short results card](../../docs/modules/quiz/results/short-results-card.md), the caller leaves both actions out and gets the titled card. The wrapper has no static mode and does not know which context it is in, so nothing on it may depend on hover or on viewport size. + +### Accessibility +- The card is a labelled region, named by its text title, or by the accessible name passed in with a component title. +- A text title is a heading, so a taker using a screen reader can move from module to module. +- Both buttons are icon-only, so each has a text name that includes the module's title. A screen holding a dozen modules must not announce a dozen identical "statistics" buttons. +- Focus order follows reading order: title component if it is interactive, statistics, info, then the body. +- Both buttons work from the keyboard and show a visible focus state. +- The pressable area of each button is at least the minimum touch target, even though the button is drawn smaller. These two buttons carry the caveats that make a result honest, so they must not be hard to hit. + +### Performance +A result screen holds one wrapper per module. The wrapper does no measuring and no work beyond layout, so its cost does not grow with what the body contains. + +## Dependencies +Relies on: + +- [Module wrapper doc](../../docs/modules/quiz/results/modules/module-wrapper.md) - the idea this implements. +- [Universal axis](./universal-axis.md) - what module bodies are made of, and the one-size rule this spec repeats. +- [Module statistics](../../docs/modules/quiz/results/module-statistics.md) and [info modals](../../docs/modules/quiz/results/info-modals.md) - what the two events lead to. Neither is written yet, which is why the wrapper stops at raising an event. + +Relied on by: + +- Every result module: [single axis](../../docs/modules/quiz/results/modules/single-axis-chart.md), [double axis](../../docs/modules/quiz/results/modules/double-axis-chart.md), [multi axis](../../docs/modules/quiz/results/modules/multi-axis-chart.md), [horizontal bar](../../docs/modules/quiz/results/modules/horizontal-bar-chart.md), [Nolan chart](../../docs/modules/quiz/results/modules/nolan-chart.md), [archetype](../../docs/modules/quiz/results/modules/archetype.md) and [traits](../../docs/modules/quiz/results/modules/traits.md). +- The [short results card](../../docs/modules/quiz/results/short-results-card.md), which renders the same modules without actions. diff --git a/mypolitics/spec/quiz/multi-axis-chart.md b/mypolitics/spec/quiz/multi-axis-chart.md new file mode 100644 index 0000000..ed00f21 --- /dev/null +++ b/mypolitics/spec/quiz/multi-axis-chart.md @@ -0,0 +1,130 @@ +# Multi axis chart + +> Technical specification of the module that shows groups of axes, each collapsed to the side that won it. + +Docs: [Multi axis chart](../../docs/modules/quiz/results/modules/multi-axis-chart.md). | Design: [Figma](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-2487) + +![Grouped axes, collapsed and open](../../assets/multi-axis-chart.png) + +## Kind +Front-end + +## Scope +One result module: a [module wrapper](./module-wrapper.md) around a list of groups. Each group is drawn as one double-sided [universal axis](./universal-axis.md) and can be opened to show every axis it holds. The module remembers which group is open; it computes no score. + +It also defines the **axis row** - a heading naming an axis and its lead, over a double-sided bar. The [Nolan chart](./nolan-chart.md) opens into the same rows. + +Not covered here: + +- **How a bar draws two values, a marker or a comparison** - the [universal axis](./universal-axis.md) spec. +- **Which side of a pair leads** - the lead rule in the [double axis chart](./double-axis-chart.md) spec. +- **The card frame and its two buttons** - the [module wrapper](./module-wrapper.md) spec. +- **What the groups are and what belongs in each** - quiz configuration. Grouping is the author's. + +## Data +| Input | Data | Meaning | Rules | +|---|---|---|---| +| Title | Text | The card's name | Optional, as in the wrapper | +| Groups | A list of a name and its axes | The structure the author defined | In the author's order | +| Axis | A start entry and an end entry | Two opposing orientations and their values | As in the double axis chart | +| Marker | Position 0-100, or off | The reference line, the same on every bar | Defaults to 50 | +| Comparison | The other party, and their values per axis | A friend's positions on the same bars | Optional | +| Statistics action, info action | Present or absent | The wrapper's two buttons | Optional, independent | + +The first axis of a group is its **headline axis**: the pair that summarises the group. It is the one drawn while the group is closed, and its lead gives the group its name. In the design's example the group "worldview" is summarised by progressivism against traditionalism, and holds five narrower axes behind it. + +## Interface +| Direction | Name | Shape | Notes | +|---|---|---|---| +| In | Everything in Data | - | Passed in by the result screen | +| Out | Statistics requested | An event with no payload | Passed through from the wrapper | +| Out | Info requested | An event with no payload | Passed through from the wrapper | + +Which group is open is the module's own state. Nothing outside is told about it. + +## Behaviour + +### Axis row +| Case | Behaviour | +|---|---| +| Heading, with a lead | The axis or group name, quiet, then the leading orientation's name, strong | +| Heading, on a tie | The name alone | +| Bar | Double-sided, with the marker, and with both poles labelled under their caps | +| Comparison values for this axis | Passed to the bar unchanged | + +### Closed group +| Case | Behaviour | +|---|---| +| A group | One axis row for its headline axis, headed by the group's name and the headline axis's lead | +| Group with more than one axis | Under the bar, a preview of the orientations inside - the icons of the start poles on one side, of the end poles on the other - and a control that opens the group | +| Group with one axis | No preview and no control. There is nothing to open | +| The list of groups | Every group, in the author's order, never sorted | + +### Open group +| Case | Behaviour | +|---|---| +| A group opened | The card shows that group alone | +| Its content | The group's heading and headline bar, then every other axis of the group as a labelled bar, in the author's order | +| The other axes | Bars with labels and no heading of their own. The labels name the poles | +| The marker | One line running through every bar of the group, since they all share its position | +| Closing | A control at the foot of the card returns to the list of groups | + +One group is open at a time. + +## States and lifecycle +| State | Condition | What is possible in it | +|---|---|---| +| List - "standard" | No group opened | Open any group that has more than one axis | +| Group open - "group open" | One group opened | Return to the list | + +The module starts as the list. The state lasts as long as the card is on screen and is not saved anywhere. + +## Rules and constraints +- **A group's name is its headline axis's lead, and nothing else.** A narrower axis with a stronger result does not rename the group. +- **A tie shows no winner.** The lead rule decides, on the values as displayed. +- **Values are drawn as given.** The module does not make a pair add up to a hundred, and a side too small to hold its number hides it, as the universal axis specifies. +- **The module never hides itself.** With no groups it is an empty card under its title. + +## Invalid and edge input +| Input | Behaviour | +|---|---| +| Group with no axes | Not drawn | +| Group without a name | The heading shows the lead alone; on a tie there is no heading | +| Axis with one value absent | Drawn by the bar with that side unfilled; the other side leads if it is above zero | +| Axis with both values absent | An empty double-sided track, and a tie | +| Orientation without an icon | Left out of the preview | +| More preview icons than fit | The row is cut at what fits. It is a preview, not a list | +| Orientation and group names longer than the room | Truncated; complete for assistive technology | +| Value outside 0-100, or not a number | Clamped, or treated as absent, as the universal axis specifies | +| Comparison values for an axis not in the module | Ignored | + +Nothing here throws. + +## Non-functional + +### Rendering contexts +The result screen, comparison mode and the generated result image. In the image the caller passes no actions and the module is drawn as the list, since nothing there can be opened. + +### Accessibility +- The groups are a list, and each group's heading is a heading. +- Each bar announces both orientations, both values and the comparison value, as the universal axis specifies. +- The preview icons are decorative. Everything they hint at is announced once the group is open. +- The open and close controls are buttons that name the group, say whether it is open, and work from the keyboard. +- When a group opens or closes, focus moves to the control that replaces the one pressed. It is never lost. +- The lead is never carried by colour alone: the heading says it in words. + +### Performance +An opened group can add dozens of bars at once. The module does no measuring; opening and closing only changes which bars are drawn. + +## Dependencies +**Build order: step 2.** Needs the [universal axis](./universal-axis.md) and the [module wrapper](./module-wrapper.md), both built, and the [double axis chart](./double-axis-chart.md) from step 1 for the lead rule. + +Relies on: + +- [Multi axis chart doc](../../docs/modules/quiz/results/modules/multi-axis-chart.md) - the idea this implements. +- [Universal axis](./universal-axis.md) and [module wrapper](./module-wrapper.md). +- [Double axis chart](./double-axis-chart.md) - the lead rule. + +Relied on by: + +- [Nolan chart](./nolan-chart.md) - opens into axis rows, so this comes first. diff --git a/mypolitics/spec/quiz/nolan-chart.md b/mypolitics/spec/quiz/nolan-chart.md new file mode 100644 index 0000000..1458781 --- /dev/null +++ b/mypolitics/spec/quiz/nolan-chart.md @@ -0,0 +1,171 @@ +# Nolan chart + +> Technical specification of the module that crosses two axes into a map and places the taker on it. + +Docs: [Nolan chart](../../docs/modules/quiz/results/modules/nolan-chart.md). | Design: [Figma](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5508-25681) + +![The compass, its levels and the maths behind them](../../assets/nolan-chart.png) + +## Kind +Front-end + +## Scope +One result module: a [module wrapper](./module-wrapper.md) around a square map of four quadrants with a dot where the taker's two scores put them. The title names the quadrant and how far out the taker is, and the card opens to show the two axes the position was built from. The module turns two pairs of values into a position and a level, and remembers whether it is open; it computes no score. + +Not covered here: + +- **How a bar draws two values, a marker or a comparison** - the [universal axis](./universal-axis.md) spec. +- **The heading-and-bar row the card opens into** - the axis row in the [multi axis chart](./multi-axis-chart.md) spec. +- **The card frame and its two buttons** - the [module wrapper](./module-wrapper.md) spec. +- **Which two axes are crossed, and what the quadrants are called** - quiz configuration. + +## Data +| Input | Data | Meaning | Rules | +|---|---|---|---| +| Horizontal axis | A name, a start entry and an end entry | The axis drawn left to right | Required | +| Vertical axis | A name, a start entry and an end entry | The axis drawn bottom to top | Required | +| Quadrants | Four, each with a colour, a moderate name and an extreme name | What each corner of the map is called | Author-supplied | +| Centre name | Text | What the middle of the map is called | Author-supplied | +| Pole names | For each of the four poles, a moderate name and an extreme name | What a lean along one axis is called | Author-supplied | +| Comparison | The other party, and their two pairs of values | A friend's position on the same map | Optional | +| Statistics action, info action | Present or absent | The wrapper's two buttons | Optional, independent | + +An entry is an orientation and a value from 0 to 100, as in the universal axis. + +Every name is a complete phrase written by the author - "moderate green", "extreme green" - and the module never builds one out of a level and a noun. In Polish and many other languages the two halves have to agree, and a composed name would be wrong half the time. + +### Position +Each axis becomes one coordinate between -1 and 1: + +> coordinate = (end value - start value) / 100 + +The start pole is at -1 and the end pole at 1. The horizontal axis runs left to right, the vertical axis bottom to top. Coordinates are computed from the exact values and shown rounded to two decimals. + +### Level +The distance from the centre decides the level of the whole position: + +> r = √(x² + y²) + +| Level | Condition | +|---|---| +| Centre | r below √2 / 3, about 0.47 | +| Moderate | r from √2 / 3, below 1 | +| Extreme | r of 1 and above | + +Along a single axis the same idea uses the coordinate alone: + +| Level | Condition | +|---|---| +| Centre | Coordinate nearer to zero than 1/3 | +| Moderate | From 1/3, short of 1 | +| Extreme | Exactly at the pole, 1 or -1 | + +The quadrant is given by the signs of the two coordinates. A coordinate of exactly zero counts toward the end pole. + +## Interface +| Direction | Name | Shape | Notes | +|---|---|---|---| +| In | Everything in Data | - | Passed in by the result screen | +| Out | Statistics requested | An event with no payload | Passed through from the wrapper | +| Out | Info requested | An event with no payload | Passed through from the wrapper | + +Whether the card is open is the module's own state. + +## Behaviour + +### Title +The title is placed in the wrapper's title slot as a component title. + +| Case | Behaviour | +|---|---| +| Centre | The centre name, in the plain title style, with no colour | +| Moderate | The quadrant's moderate name, as coloured text on a pale tint of the quadrant's colour | +| Extreme | The quadrant's extreme name, on the quadrant's colour at full strength | +| No position | The "no result" wording, in the plain title style | + +### Map +| Case | Behaviour | +|---|---| +| Always | A square of four quadrants, each in a pale tint of its colour | +| Moderate or extreme | The taker's quadrant is filled with its colour at full strength | +| Centre | No quadrant is filled | +| The taker | A dot with a halo at the position | +| Dot at an edge or a corner | Its centre is placed exactly; whatever falls outside the map is cut off | +| No position | No dot and no filled quadrant | + +Each axis is named beside the map with its coordinate: the horizontal axis under the map, the vertical axis along its side. + +### Opened +| Case | Behaviour | +|---|---| +| Closed | A control at the foot of the card opens it | +| Opened | Two axis rows appear under the map, horizontal axis first, and the control at the foot closes the card | +| A row's heading | The axis's name, then the pole name for the taker's level along that axis | +| A row's heading at the centre level | The axis's name alone | +| A row's bar | Double-sided, with the marker and no labels. The caps carry the poles' icons | +| A row's colours | The side the taker leans to takes the quadrant's colour; the other side is neutral | + +### Comparison +| Case | Behaviour | +|---|---| +| Comparison present | The other party's image is drawn at their position, on a hatched disc | +| Their quadrant | Never filled. The filled quadrant is always the taker's | +| Their position at an edge or a corner | Moved inward so the image stays fully on the map | +| Both at the same position | The other party's image is drawn on top; the taker's halo stays visible around it | +| The other party has no position | No second marker | +| Opened | Both rows carry the comparison band, as the universal axis draws it | + +## States and lifecycle +| State | Condition | What is possible in it | +|---|---|---| +| Closed - "standard" | Not opened | Open the card | +| Open | Opened | Read the two axes, close the card | + +The module starts closed. The state lasts as long as the card is on screen and is not saved anywhere. + +## Rules and constraints +- **No position is not the centre.** A taker the quiz could not place gets no dot and the "no result" wording. The centre is reserved for a taker who was placed there. +- **The thresholds are constants.** They are the same for every quiz and are not configuration; the info button can state them in one sentence. +- **The map is always square**, whatever the card's width. +- **Two dots at most.** Group comparison is not a case this module handles. +- **Names come from the author, colours come from the quadrant.** The module never takes the title's colour from an orientation. + +## Invalid and edge input +| Input | Behaviour | +|---|---| +| An axis with either value absent or not a number | No position | +| Value outside 0-100 | Clamped before the coordinate is computed | +| A pair that does not add up to 100 | The coordinate is still the difference over 100 | +| Quadrant name missing for the taker's level | The quadrant's other name, and failing that the centre name | +| Centre name missing | The title is empty and the wrapper draws its header without one | +| Pole name missing | The row's heading shows the axis's name alone | +| Quadrant without a colour | The neutral fallback: tint, fill and title | +| Axis name longer than the room | Truncated; complete for assistive technology | +| Title longer than the title slot | Truncated by the wrapper; complete for assistive technology | +| Other party without an image | A neutral placeholder on the hatched disc | + +Nothing here throws. + +## Non-functional + +### Rendering contexts +The result screen, comparison mode and the generated result image. In the image the caller passes no actions and the module is drawn closed. + +### Accessibility +- The map is announced as a single image. Its description carries the quadrant name and level, both axis names with their coordinates, and the other party's quadrant when there is a comparison. +- The level is never carried by colour alone: the title says it in words. +- The other party is marked by a hatched disc and their image, not by colour. +- The open and close control is a button that says what it does and whether the card is open, and works from the keyboard. +- The card is named after its title, through the accessible name the wrapper takes for a component title. + +## Dependencies +**Build order: step 3.** Needs the [universal axis](./universal-axis.md) and the [module wrapper](./module-wrapper.md), both built, and the [multi axis chart](./multi-axis-chart.md) from step 2 for the axis row. + +Relies on: + +- [Nolan chart doc](../../docs/modules/quiz/results/modules/nolan-chart.md) - the idea this implements. +- [Universal axis](./universal-axis.md) and [module wrapper](./module-wrapper.md). +- [Multi axis chart](./multi-axis-chart.md) - the axis row. +- [Double axis chart](./double-axis-chart.md) - the double-sided bar it opens into. + +Relied on by nothing in this folder. diff --git a/mypolitics/spec/quiz/single-axis-chart.md b/mypolitics/spec/quiz/single-axis-chart.md new file mode 100644 index 0000000..104b58a --- /dev/null +++ b/mypolitics/spec/quiz/single-axis-chart.md @@ -0,0 +1,99 @@ +# Single axis chart + +> Technical specification of the module that shows one orientation as one bar. + +Docs: [Single axis chart](../../docs/modules/quiz/results/modules/single-axis-chart.md). | Design: [Figma](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5508-22154) + +![Single axis states and examples](../../assets/single-axis-chart.png) + +## Kind +Front-end + +## Scope +One result module: a [module wrapper](./module-wrapper.md) whose title is the orientation and whose body is a single one-sided [universal axis](./universal-axis.md). It holds no state and computes no score. + +Not covered here: + +- **How the bar draws a value, a marker or a comparison** - the [universal axis](./universal-axis.md) spec. +- **The card frame and its two buttons** - the [module wrapper](./module-wrapper.md) spec. +- **Which orientation the module points at** - quiz configuration. + +## Data +| Input | Data | Meaning | Rules | +|---|---|---|---| +| Orientation | Name, image, colour | What the card is about | Required. Image and colour are optional | +| Value | Number, 0-100 | The orientation's score | Optional. Absent means the quiz has no answers behind this orientation | +| Marker | Position 0-100, or off | The reference line on the bar | Defaults to 50 | +| Comparison | Orientation and value | The other party on the same bar | Optional | +| Statistics action, info action | Present or absent | The wrapper's two buttons | Optional, independent | + +The scale is absolute: the value is drawn as given, never relative to another module on the screen. + +## Interface +| Direction | Name | Shape | Notes | +|---|---|---|---| +| In | Everything in Data | - | Passed in by the result screen | +| Out | Statistics requested | An event with no payload | Passed through from the wrapper | +| Out | Info requested | An event with no payload | Passed through from the wrapper | + +## Behaviour + +### Title +The title is a chip holding the orientation's icon and name, placed in the wrapper's title slot as a component title. + +| Case | Behaviour | +|---|---| +| Value at or above the marker position | Emphasised: the chip is filled with the orientation's colour | +| Value below the marker position | Quiet: the chip is outlined and its content muted | +| Marker switched off | The emphasis line stays at 50 | +| Value absent | Quiet | +| Orientation without an image | The chip shows the name alone | + +The chip is never interactive. + +### Bar +| Case | Behaviour | +|---|---| +| Value present | A one-sided bar filled from the start cap, the orientation's image on the cap | +| Value is zero | The cap and an unfilled track, with no number | +| Value absent | An empty track with no cap, as the universal axis draws "no orientation" | +| Comparison present | Passed to the bar unchanged; the bar draws the band and the other party's image | + +Labels under the bar are off: the title already names the orientation. + +## Rules and constraints +- The module adds nothing to the bar's own rules. Value placement, rounding, the marker and the comparison band behave exactly as the universal axis specifies. +- An empty track is a state, not an error. The card is still drawn for an orientation with no value. +- The module never hides itself. Leaving it out is the result screen's decision. + +## Invalid and edge input +| Input | Behaviour | +|---|---| +| Value outside 0-100 | Clamped by the bar. The title's emphasis uses the clamped value | +| Value not a number | Treated as absent | +| Orientation name missing or empty | The chip shows the image alone. With neither, the wrapper gets no title | +| Orientation name longer than the title slot | Truncated by the wrapper, complete for assistive technology | +| Orientation without a colour | The neutral fallback, on the chip and on the bar | +| Marker outside 0-100 | Clamped; the emphasis line moves with it | + +Nothing here throws. + +## Non-functional + +### Rendering contexts +The result screen, comparison mode and the generated result image. In the image the caller passes no actions. + +### Accessibility +- The card is named after the orientation, through the accessible name the wrapper takes for a component title. +- The bar announces the orientation, its value and the comparison value, as the universal axis specifies. +- Emphasis is never the only signal: the number on the bar says the same thing. + +## Dependencies +**Build order: step 1.** Needs the [universal axis](./universal-axis.md) and the [module wrapper](./module-wrapper.md), both built. Nothing else in this folder has to exist first, and it can be built alongside the [double axis chart](./double-axis-chart.md), [traits](./traits.md) and the [header](./header.md). + +Relies on: + +- [Single axis chart doc](../../docs/modules/quiz/results/modules/single-axis-chart.md) - the idea this implements. +- [Universal axis](./universal-axis.md) and [module wrapper](./module-wrapper.md). + +Relied on by nothing in this folder. The [horizontal bar chart](./horizontal-bar-chart.md) and the [archetype](./archetype.md) use the same one-sided bar, but compose the universal axis directly. diff --git a/mypolitics/spec/quiz/traits.md b/mypolitics/spec/quiz/traits.md new file mode 100644 index 0000000..079baba --- /dev/null +++ b/mypolitics/spec/quiz/traits.md @@ -0,0 +1,112 @@ +# Traits + +> Technical specification of the module that shows the traits a taker earned. + +Docs: [Traits](../../docs/modules/quiz/results/modules/traits.md). | Design: [Figma](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-26739) + +![Traits, alone and in comparison](../../assets/traits.png) + +## Kind +Front-end + +## Scope +One result module: a [module wrapper](./module-wrapper.md) around a set of pills, one per earned trait. It is the only result module with no bar in it, because a trait is held or not held and has no score to draw. It holds no state and decides nothing about who earned what. + +Not covered here: + +- **Whether a trait is earned** - scoring. The module receives the traits that already are. +- **The card frame and its two buttons** - the [module wrapper](./module-wrapper.md) spec. +- **The mid-quiz card that announces a trait** - the checkpoints. + +## Data +| Input | Data | Meaning | Rules | +|---|---|---|---| +| Title | Text | The card's name | Optional, as in the wrapper | +| Traits | A list of orientations | Every trait the quiz defines | In the quiz's order | +| Earned | Which of those traits the taker earned | The taker's result | May be none | +| Comparison | The other party, and which traits they earned | A friend's traits laid over the taker's | Optional | +| Statistics action, info action | Present or absent | The wrapper's two buttons | Optional, independent | + +A trait is an orientation: a name, an icon and a colour, all author-supplied and untrusted. The other party is an orientation too, their avatar being the image. + +The quiz's traits are configuration and who earned what is a result, so the two arrive separately. The order is the quiz's own. It means nothing - every trait is equally earned - but it is stable, so the same result always draws the same way, with or without a comparison. A trait nobody earned is not drawn. + +## Interface +| Direction | Name | Shape | Notes | +|---|---|---|---| +| In | Everything in Data | - | Passed in by the result screen | +| Out | Statistics requested | An event with no payload | Passed through from the wrapper | +| Out | Info requested | An event with no payload | Passed through from the wrapper | + +## Behaviour + +### Pills +| Case | Behaviour | +|---|---| +| A trait | One pill: the trait's icon, then its name, on the trait's colour | +| Several traits | Pills flow in a row and wrap onto further rows, in the order given | +| A trait without an icon | The pill shows the name alone | +| A pill pressed | Nothing happens. Pills are not interactive | + +### Comparison +With a comparison, the set drawn is every trait either party earned, in the quiz's order. + +| Case | Behaviour | +|---|---| +| Both earned it | A solid pill carrying the other party's avatar on its corner | +| Only the taker earned it | A solid pill with no avatar | +| Only the other party earned it | A hatched pill carrying the other party's avatar | +| The other party earned nothing | The taker's pills, all without an avatar | + +The hatching is the same pattern the universal axis uses for comparison, so "hatched means theirs" holds across the whole screen. + +### Empty +| Case | Behaviour | +|---|---| +| No traits, no comparison | The card is drawn with one line of text saying no traits were earned | +| No traits for either party | The same line | +| The taker earned none, the other party did | The other party's pills, all hatched | + +An empty set is a real outcome of a strict rule, so the card says so instead of showing a blank body that reads as broken. + +## Rules and constraints +- **A set, not a ranking.** The module never sorts, never numbers and never emphasises one pill over another. +- **One size of pill.** A pill does not grow or shrink with the number of traits. +- **No score.** A trait earned by a hair looks the same as one earned outright; the module has no input that could say otherwise. +- **The module never hides itself.** Leaving it out of a quiz with no traits configured is the result screen's decision. + +## Invalid and edge input +| Input | Behaviour | +|---|---| +| Trait name longer than the card | The pill is as wide as the card at most; the name is truncated to one line and stays complete for assistive technology | +| Trait without a name | Not drawn | +| Trait without a colour | The neutral fallback colour | +| Trait with a light colour | The label stays readable: dark text on a light pill, light text on a dark one | +| The same trait listed twice | Drawn once | +| Other party without an avatar | A neutral avatar placeholder in the same place | +| Comparison that names no earned traits | Treated as the other party having earned nothing | +| An earned trait the quiz does not define | Ignored | + +Nothing here throws. Trait names are the sharpest words an author writes, and a bad one must not take the card down - whether it should be shown at all is moderation's question, not this module's. + +## Non-functional + +### Rendering contexts +The result screen, comparison mode and the generated result image. In the image the caller passes no actions. + +### Accessibility +- The pills are a list, and each item is its trait's name. +- In comparison each item also says whose it is in words - shared with the other party, or only theirs. Hatching and the avatar are never the only carriers. +- The trait icon is decorative. +- The empty line is ordinary text, read like any other. + +## Dependencies +**Build order: step 1.** Needs the [module wrapper](./module-wrapper.md), which is built. It does not use the universal axis, and it can be built alongside the [single axis chart](./single-axis-chart.md), the [double axis chart](./double-axis-chart.md) and the [header](./header.md). + +Relies on: + +- [Traits doc](../../docs/modules/quiz/results/modules/traits.md) - the idea this implements. +- [Module wrapper](./module-wrapper.md). +- [Universal axis](./universal-axis.md) - only for the hatching pattern, which must match. + +Relied on by nothing in this folder. diff --git a/mypolitics/spec/quiz/universal-axis.md b/mypolitics/spec/quiz/universal-axis.md index e0ca0ce..2d83b49 100644 --- a/mypolitics/spec/quiz/universal-axis.md +++ b/mypolitics/spec/quiz/universal-axis.md @@ -33,6 +33,8 @@ A **value** is a share of the track from 0 to 100. The component never normalise Each entry is anchored to the cap that belongs to it, so an end entry alone renders as a one-sided bar filling from the right. +The mode follows which entries are passed, not which have values. An entry without a value means the orientation is known and its score is not: it keeps its cap, so a double-sided bar with one unknown side stays double-sided. It draws like a value of zero and is announced as missing. + ## Fill geometry - Each fill starts at its own cap and extends by its value as a share of the track width. - Any remainder stays unfilled. On a double-sided bar with values that do not reach 100 between them, the gap sits in the middle. @@ -73,7 +75,7 @@ Hatching exists only for comparison. Without a comparison the bar has no hatched | Other is ahead | Hatched band from the taker's value to theirs, continuing past the fill | | Other is behind | Hatched band from their value to the taker's, drawn over the fill | | Values are equal | No band; the image alone marks the shared position | -| Taker has no entry | The whole track is hatched and only the other party's image is positioned | +| Taker has no entry, or an entry without a value | The whole track is hatched and only the other party's image is positioned | | Other is at 0 or 100 | The image is clamped so it stays fully inside the track | | Double-sided bar | The band is measured against the start entry on the same shared track | @@ -91,7 +93,7 @@ Draw order is fills, then marker, then band, then the other party's image, which |---|---| | Value below 0 or above 100 | Clamped into range | | Values that together exceed the track | Both fills scaled proportionally so they meet without overlapping | -| Value missing or not a number | Treated as an absent entry | +| Value missing or not a number | The entry stays present: its cap and label are drawn, with no fill and no number | | Orientation without an image | Cap renders with colour only | | Orientation without a colour | Falls back to a neutral colour | | Orientation name longer than the track | Truncated visually, preserved for assistive technology | diff --git a/mypolitics/tasks/epics/modules/cycle-1/double-axis-chart.md b/mypolitics/tasks/epics/modules/cycle-1/double-axis-chart.md new file mode 100644 index 0000000..be7a23b --- /dev/null +++ b/mypolitics/tasks/epics/modules/cycle-1/double-axis-chart.md @@ -0,0 +1,257 @@ +Double axis chart - states and examples + +# Story + +As a user reading my quiz result, I want two opposing orientations shown on one bar, with both poles named and the title telling me which side I landed on, so that I read the result as a trade-off rather than as a score. + +# Component properties + +**Component:** `DoubleAxisChart` +**Location:** `src/components/results/modules/DoubleAxisChart/` +**Shared:** no - a result module, in the `results` domain + +A `ModuleWrapper` whose title names the leading side and whose body is one double-sided `UniversalAxis` with labels. **No state and no scoring.** This task also creates the **lead rule** as a shared util, because `MultiAxisChart` uses the same one. + +```ts +// src/types/results.ts +export interface ResultEntry { + orientation: AxisOrientation; // from UniversalAxis.types + value?: number; // 0-100; absent = no answers behind it +} + +export interface DoubleAxisChartProps { + start: ResultEntry; // the pole on the left cap + end: ResultEntry; // the pole on the right cap + marker?: number | false; // default 50 + comparison?: AxisEntry; // the other party, passed to the bar unchanged + onStatsClick?: () => void; + onInfoClick?: () => void; +} + +// src/utils/results/getAxisLead.ts +export type AxisLead = 'start' | 'end' | null; +export const getAxisLead = (start?: number, end?: number): AxisLead => ... +``` + +`ResultEntry` belongs in `src/types/results.ts`. Create the file if it does not exist yet - other module tasks of this epic use the same type - and reuse it if it does. + +# Behaviour + +The [spec](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/double-axis-chart.md) is the source of truth for every case below; the [Figma frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-2387) is the source of truth for sizes, spacing, type, icons and colours. + +### Lead + +Decided on the values as the taker sees them - rounded to whole numbers - so the title can never contradict the bar. + +| Case | Lead | +|---|---| +| One displayed value is higher | That side | +| Both displayed values are equal, including both zero | None - a tie | +| One value absent | The side that has a value, if it is above zero | +| Both values absent | None | + +Values are clamped to 0-100 before they are compared. When the two exceed 100 together the bar scales them, but the lead still uses the values as given. + +### Title + +| Case | Behaviour | +|---|---| +| There is a lead | A chip with the leading orientation's icon and name, filled with its colour | +| Tie | A neutral chip naming both poles, start first, with no icon and no colour | +| Leading orientation without an image | The chip shows the name alone | + +The title names the side the taker landed on, not the axis. The chip is never interactive. + +### Bar + +| Case | Behaviour | +|---|---| +| Both values present | `UniversalAxis` with `start` and `end` | +| Values that do not reach 100 together | Drawn as given; the gap stays in the middle. **Do not normalise** | +| One value absent | That side has no fill and no number; its cap and label still show | +| Both values absent | An empty double-sided track with both caps and labels | +| Comparison present | Passed to the bar unchanged | + +`showLabels` is always on. Do not re-implement anything the bar does. + +### Invalid and edge input + +| Input | Behaviour | +|---|---| +| Value not a number | Treated as absent | +| Orientation name missing | Its label row stays reserved and empty. If it leads, the chip shows the image alone | +| Names longer than the room | Labels truncated by the bar, the title by the wrapper; complete for assistive technology | +| Orientation without a colour | The neutral fallback, on the chip and on the bar | +| Both poles are the same orientation | Drawn as given | + +### Accessibility + +- Pass `ariaLabel` to `ModuleWrapper`: the leading orientation's name, or both names on a tie. +- The lead is never carried by colour alone: the title says it in words. + +One side absent is **not** the bar's one-sided mode: both caps and both labels must still show. Pass the entry to the bar with its value left out. `UniversalAxis` keeps the cap and label of such an entry once the fix task this one is blocked by has landed; do not work around it by passing 0. + +# Copy + +Polish is the source language, and every string is translatable. Each one below goes through a Lingui macro - `` for JSX text, `t` or `msg` for attributes and ARIA text - with no hardcoded literals. After adding them run `yarn i18n:extract`, fill in the English entries in `src/locales/en/messages.po`, and commit both `.po` files. The wording is a proposal: adjust it if needed, but keep the meaning. + +| Where | Polish (source) | English | +|---|---|---| +| Tie title and card name | {start} / {end} | {start} / {end} | + +# Athena components to use + +- `ModuleWrapper` and `UniversalAxis` from `src/components/shared/` - do not rebuild either. +- The title chip is `OrientationChip` in `src/components/results/modules/OrientationChip/`. **The `SingleAxisChart` task needs the same chip**: create it if it does not exist yet, reuse and extend it if it does. The tie title is its neutral look. +- Orientation colours are author-supplied data, not design tokens. Apply them at runtime through a CSS custom property, exactly as `UniversalAxis` does (approved in [mypolitics-app#61](https://github.com/gi-org-pl/mypolitics-app/pull/61#issuecomment-6007060837)), and accept only values that pass `SAFE_COLOR_PATTERN`. That check lives in `UniversalAxis.constants.ts` today: promote it to a shared place instead of copying it. Every other colour comes from the palette. + +# Out of scope + +- Anything the bar draws - it is `UniversalAxis`. +- The card frame and its two buttons - `ModuleWrapper`. +- Which orientations are paired, and checking that a pairing makes sense. +- Making a pair add up to a hundred. + +# Files to create + +``` +src/components/results/modules/DoubleAxisChart/ +├── DoubleAxisChart.tsx +├── DoubleAxisChart.test.tsx +├── DoubleAxisChart.types.ts +└── DoubleAxisChart.stories.tsx + +src/components/results/modules/OrientationChip/ # only if it does not exist yet + +src/utils/results/ +├── getAxisLead.ts +└── getAxisLead.test.ts + +src/types/results.ts # ResultEntry - only if it does not exist yet +``` + +No empty files - add a constants file or a subcomponent only if the view needs one. + +# Unit test cases (BDD) + +```ts +describe('getAxisLead()', () => { + describe('given one higher value', () => { + it('returns that side', ...); + }); + describe('given values that round to the same number', () => { + it('returns null', ...); + }); + describe('given both values at zero', () => { + it('returns null', ...); + }); + describe('given one absent value', () => { + it('returns the other side when it is above zero', ...); + it('returns null when the other side is zero', ...); + }); + describe('given both values absent', () => { + it('returns null', ...); + }); + describe('given values outside 0-100', () => { + it('compares the clamped values', ...); + }); + describe('given a value that is not a number', () => { + it('treats it as absent', ...); + }); +}); + +describe('', () => { + describe('given a lead', () => { + it('renders a chip with the leading orientation, in its colour', ...); + it('names the card after the leading orientation', ...); + }); + describe('given a tie', () => { + it('renders a neutral chip naming both poles, start first', ...); + it('names the card after both poles', ...); + }); + describe('given both values', () => { + it('renders a double-sided bar with labels', ...); + it('does not normalise values that do not reach 100', ...); + }); + describe('given one absent value', () => { + it('keeps that side cap and label, with no fill and no number', ...); + }); + describe('given both values absent', () => { + it('renders an empty double-sided track with both caps and labels', ...); + }); + describe('given a comparison', () => { + it('passes it to the bar', ...); + }); + describe('given a leading orientation without an image', () => { + it('renders the chip with the name alone', ...); + }); + describe('given marker is false', () => { + it('renders the bar without a marker', ...); + }); + describe('given handlers', () => { + it('passes onStatsClick and onInfoClick to the wrapper', ...); + }); +}); +``` + +# Storybook stories + +**Figma, in the frame's order** +- `Standard` - placeholder orientations, 69 / 31 +- `Example` - 69 / 31 +- `Comparison` + +**Edge** +- `Tie` - 50 / 50, neutral title +- `NarrowLead` - 51 / 49 +- `GapInTheMiddle` - 47 / 31 +- `OneValueMissing`, `BothValuesMissing` +- `SmallSide` - one side too small to hold its number +- `CustomMarker`, `NoMarker` +- `LongNames`, `NoColor`, `NoImage` + +# Remember about standards + +- Use the standard colors palette, never add colors directly (check https://tailwindcss.com/docs/colors and our color palette in the `src/index.css` file and in [athena](https://github.com/gi-org-pl/athena/blob/main/src/index.css)) +- Create unit tests with Vitest for 100% of the code created if feasible (check our [testing convention](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/TESTING_CONVENTION.md)) +- Create a Storybook story for the component with all possible props variants of the component +- Comply with [the component structure](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/COMPONENT_STRUCTURE.md) +- Name the branch `feature/double-axis-chart-66`, following [Conventional Branch](https://conventional-branch.github.io/) - never keep a generated or default branch name +- Copy is Polish by default and fully translatable: every literal string and every ARIA text goes through Lingui macros, and the English entries are filled in (see Copy). Names and values come from props +- The PR follows the repository's pull request template, with screenshots of the Figma stories next to the Figma frame + +# Dependencies + +- `UniversalAxis` - done ([#60](https://github.com/gi-org-pl/mypolitics-app/issues/60)) +- `ModuleWrapper` - done ([#62](https://github.com/gi-org-pl/mypolitics-app/issues/62)) +- **Blocked by `UniversalAxis: entry without a value`** (#73) - a side with no value must keep its cap and label, which the bar cannot draw before that fix + +# Resources + +- [Spec - Double axis chart](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/double-axis-chart.md) - every case, in full +- [Docs - Double axis chart](https://github.com/gi-org-pl/product/blob/main/mypolitics/docs/modules/quiz/results/modules/double-axis-chart.md) - why the module exists +- [Figma - Double axis chart frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-2387) - [standard](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-2400) | [example](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5507-12032) | [comparison](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-39086) +- [Front-end standards](https://github.com/Generacja-Innowacja/gi-tech-standards/tree/main/docs/frontend) +- [Storybook docs](https://storybook.js.org/docs/writing-stories) +- [Tailwind docs](https://tailwindcss.com/docs/) +- [Vitest docs](https://vitest.dev/guide/) + +# Definition of Done + +- [ ] Code follows folder structure (`docs/frontend/conventions/PROJECT_STRUCTURE.md`) +- [ ] Naming follows `docs/frontend/conventions/NAMING.md` +- [ ] Component layout follows `docs/frontend/conventions/COMPONENT_STRUCTURE.md` +- [ ] The lead is decided on rounded values by `getAxisLead`, a pure util with its own tests +- [ ] A tie renders a neutral chip naming both poles +- [ ] The body is one double-sided `UniversalAxis` with labels; nothing of the bar is re-implemented +- [ ] Values are never normalised +- [ ] The author-supplied colour is applied only through a CSS custom property and validated +- [ ] `OrientationChip` exists once and is shared with `SingleAxisChart` +- [ ] Invalid input degrades as in the table, without throwing +- [ ] Unit tests added, BDD style, coverage ≥95% on all new files +- [ ] Storybook stories added for all variants listed above +- [ ] Biome lint clean +- [ ] TypeScript clean (no `any`, no `@ts-ignore`) +- [ ] Every string from the Copy section goes through a Lingui macro, with Polish as the source; `yarn i18n:extract` run, English entries translated, `.po` files committed +- [ ] Branch named `feature/double-axis-chart-66` +- [ ] CI green: build, lint, test diff --git a/mypolitics/tasks/epics/modules/cycle-1/horizontal-bar-chart.md b/mypolitics/tasks/epics/modules/cycle-1/horizontal-bar-chart.md new file mode 100644 index 0000000..ad363a9 --- /dev/null +++ b/mypolitics/tasks/epics/modules/cycle-1/horizontal-bar-chart.md @@ -0,0 +1,317 @@ +Horizontal bar chart - flat and grouped + +# Story + +As a user reading my quiz result, I want the orientations ranked by how well they match me, best first, with only the top few shown until I ask for more - and broken down by category when the quiz has categories - so that I can see who is closest to me without reading a table. + +# Component properties + +**Component:** `HorizontalBarChart` +**Location:** `src/components/results/modules/HorizontalBarChart/` +**Shared:** no - a result module, in the `results` domain + +A `ModuleWrapper` around a ranked list of one-sided `UniversalAxis` bars. It sorts what it is given and remembers what the taker opened; **no scoring**. This task also creates **`RankedRow`** as its own component, because the `Archetype` module's ranking is a list of the same rows. + +```ts +export interface RankedBadge { + iconUrl?: string; + text?: string; // short visible text, e.g. "Oficjalne" + label?: string; // text name for an icon-only badge +} + +export interface RankedEntry { + orientation: AxisOrientation; // from UniversalAxis.types + value?: number; // 0-100 + badge?: RankedBadge; +} + +export interface RankedCategory { + name?: string; + entries: RankedEntry[]; +} + +export interface RankedComparison { + party: AxisOrientation; // the other party + values: Record; // their value, by orientation id +} + +export interface HorizontalBarChartProps { + title?: string; + entries?: RankedEntry[]; + categories?: RankedCategory[]; // when present, the list is grouped and `entries` is ignored + visibleRows?: number; // rows before the fold in a flat list; default 3 + comparison?: RankedComparison; + onStatsClick?: () => void; + onInfoClick?: () => void; +} + +// src/components/results/modules/RankedRow/ +export interface RankedRowProps { + entry: RankedEntry; + comparison?: AxisEntry; +} +``` + +What is open is local state (`useState`). It is not saved anywhere and nothing outside is told about it. + +# Behaviour + +The [spec](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/horizontal-bar-chart.md) is the source of truth for every case below; the [Figma frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-1414) is the source of truth for sizes, spacing, type, icons and colours. + +### Ranked row + +| Case | Behaviour | +|---|---| +| A row | The orientation's name, then its badge if it has one, and under them a one-sided bar with the orientation's image on the cap | +| The bar | `marker={false}` and no labels | +| Value absent | An empty track with no cap | +| Comparison value for this orientation | Passed to the bar | +| No comparison value for this orientation | The row is drawn without an overlay | +| A row or a badge pressed | Nothing happens | + +A badge is an icon alone, or an icon with a short text. It never changes a row's position, colour or bar. + +### Order + +| Case | Behaviour | +|---|---| +| Entries | Sorted by value, highest first | +| Equal values | Keep the order they were given in | +| Entries without a value | Last, in the order they were given | +| Comparison present | The order does not change | + +### Flat list + +| Case | Behaviour | +|---|---| +| More entries than `visibleRows` | The top rows are shown, with a control under them that opens the rest | +| Opened | Every row is shown, and the control at the foot of the card closes the list again | +| As many entries as `visibleRows`, or fewer | Every row is shown and there is no control | + +### Grouped list + +| Case | Behaviour | +|---|---| +| A category | A heading with the category's name and its leading orientation, the leader's badge, the leader's bar, and a control that opens the category | +| The leader | The first row of the category's own ranking | +| Category where no entry has a value above zero | The heading says there is no result ("Brak wyniku"), the bar is an empty track, and the control still opens the category | +| The list of categories | In the order given, never sorted and never cut | +| A category opened | **The card shows that category alone**: its heading and leader, then the rest of its ranking in full, and a control at the foot that returns to the list of categories | + +One category is open at a time, and the fold does not apply inside one. + +### States + +| State | Figma label | +|---|---| +| Flat, folded | "no categories - default" | +| Flat, open | "no categories - expanded" | +| Grouped | "group by category" | +| Category open | "group by category - selected" | + +The module starts folded, or grouped with nothing open. + +### Invalid and edge input + +| Input | Behaviour | +|---|---| +| `visibleRows` below 1, or not a whole number | The default of 3 | +| Both `entries` and `categories` passed | The categories win | +| Category with no entries | Drawn as a category with no result, and with no control to open it | +| Category without a name | The heading shows the leader alone | +| Entry without an orientation name | The bar is drawn under an empty name | +| Name and badge longer than the row | The name is truncated first, complete for assistive technology; the badge keeps its text | +| Badge with a text but no icon, or an icon but no text | Drawn with what it has | +| The same orientation twice in one list | Drawn twice | +| Comparison value for an orientation not in the list | Ignored | +| No entries at all | An empty card under its title | + +### Accessibility + +- The rows are an ordered list, so the rank is announced and not only seen. +- A badge is announced with its row. An icon-only badge uses `label` as its text name. +- The open and close controls are buttons that say what they do and whether the list is open, and work from the keyboard. +- When a category opens or closes, focus moves to the control that replaces the one pressed. It is never lost. + +# Copy + +Polish is the source language, and every string is translatable. Each one below goes through a Lingui macro - `` for JSX text, `t` or `msg` for attributes and ARIA text - with no hardcoded literals. After adding them run `yarn i18n:extract`, fill in the English entries in `src/locales/en/messages.po`, and commit both `.po` files. The wording is a proposal: adjust it if needed, but keep the meaning. + +| Where | Polish (source) | English | +|---|---|---| +| Category heading, no result | Brak wyniku | No result | +| Control, open the flat list | Pokaż wszystkie | Show all | +| Control, close the flat list | Pokaż mniej | Show less | +| Control, open a category | Pokaż kategorię: {name} | Show category: {name} | +| Control, return to the categories | Wróć do kategorii | Back to categories | + +# Athena components to use + +- `ModuleWrapper` and `UniversalAxis` from `src/components/shared/` - do not rebuild either. +- **`Badge`** for the text badge, if it matches the small labelled badge in Figma. If it does not, say why in the PR. +- **`Button`** with `isIconButton` for the open and close controls, if it can match the chevron controls in Figma. +- No list, accordion or chart library. + +# Out of scope + +- Anything the bar draws - it is `UniversalAxis`. +- Deciding who gets a badge and what it says. +- Scoring, and choosing the categories. +- Saving what the taker opened. + +# Files to create + +``` +src/components/results/modules/HorizontalBarChart/ +├── HorizontalBarChart.tsx +├── HorizontalBarChart.test.tsx +├── HorizontalBarChart.types.ts +├── HorizontalBarChart.constants.ts # DEFAULT_VISIBLE_ROWS +├── HorizontalBarChart.stories.tsx +└── utils/ + ├── sortRankedEntries.ts # pure: stable sort, entries without a value last + └── sortRankedEntries.test.ts + +src/components/results/modules/RankedRow/ +├── RankedRow.tsx +├── RankedRow.test.tsx +├── RankedRow.types.ts +└── RankedRow.stories.tsx +``` + +Split the grouped view into a subcomponent only if the main view gets hard to read. No empty files. + +# Unit test cases (BDD) + +```ts +describe('sortRankedEntries()', () => { + it('sorts by value, highest first', ...); + it('keeps the given order for equal values', ...); + it('puts entries without a value last, in the given order', ...); + it('treats a value that is not a number as absent', ...); + it('does not mutate the input', ...); +}); + +describe('', () => { + it('renders the name and a one-sided bar with no marker and no labels', ...); + it('renders an icon-only badge with its text name', ...); + it('renders a badge with icon and text', ...); + it('renders an empty track when the value is absent', ...); + it('passes a comparison to the bar', ...); + it('is not interactive', ...); +}); + +describe('', () => { + describe('given a flat list longer than visibleRows', () => { + it('renders the top rows and an open control', ...); + describe('when the control is pressed', () => { + it('renders every row and a close control', ...); + }); + describe('when the close control is pressed', () => { + it('folds the list again', ...); + }); + }); + describe('given a flat list no longer than visibleRows', () => { + it('renders every row and no control', ...); + }); + describe('given an invalid visibleRows', () => { + it('falls back to 3', ...); + }); + describe('given categories', () => { + it('renders each category with its name, leader and the leader bar', ...); + it('keeps the categories in the given order', ...); + it('ignores entries when both are passed', ...); + describe('when a category is opened', () => { + it('renders that category alone, with its full ranking', ...); + it('does not apply the fold inside it', ...); + it('moves focus to the control that returns to the list', ...); + }); + describe('when the return control is pressed', () => { + it('renders the list of categories again', ...); + }); + }); + describe('given a category with no value above zero', () => { + it('says there is no result and renders an empty track', ...); + it('can still be opened', ...); + }); + describe('given a category with no entries', () => { + it('renders it with no result and no control', ...); + }); + describe('given a comparison', () => { + it('passes each orientation value to its row', ...); + it('renders rows without a comparison value with no overlay', ...); + it('does not change the order', ...); + it('ignores values for orientations not in the list', ...); + }); + describe('given no entries', () => { + it('renders an empty card under its title', ...); + }); + describe('accessibility', () => { + it('renders the rows as an ordered list', ...); + it('says on each control whether the list is open', ...); + }); +}); +``` + +# Storybook stories + +**Figma, in the frame's order** - use `play()` to open a list where the state needs it +- `FlatFolded` - "no categories - default" +- `FlatOpen` - "no categories - expanded" +- `Grouped` - "group by category" +- `CategoryOpen` - "group by category - selected" + +**Examples** +- `Candidates`, `CandidatesComparison`, `CandidatesOpen`, `CandidatesGrouped`, `CandidatesCategoryOpen` + +**Edge** +- `NothingBehindTheFold`, `CustomVisibleRows` +- `CategoryWithoutResult`, `EmptyCategory` +- `EqualValues`, `MissingValues` +- `LongNames`, `Empty` + +# Remember about standards + +- Use the standard colors palette, never add colors directly (check https://tailwindcss.com/docs/colors and our color palette in the `src/index.css` file and in [athena](https://github.com/gi-org-pl/athena/blob/main/src/index.css)) +- Create unit tests with Vitest for 100% of the code created if feasible (check our [testing convention](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/TESTING_CONVENTION.md)) +- Create a Storybook story for the component with all possible props variants of the component +- Comply with [the component structure](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/COMPONENT_STRUCTURE.md) +- Name the branch `feature/horizontal-bar-chart-69`, following [Conventional Branch](https://conventional-branch.github.io/) - never keep a generated or default branch name +- Copy is Polish by default and fully translatable: every literal string and every ARIA text goes through Lingui macros, and the English entries are filled in (see Copy). Names and values come from props +- The PR follows the repository's pull request template, with screenshots of the Figma stories next to the Figma frame + +# Dependencies + +- `UniversalAxis` - done ([#60](https://github.com/gi-org-pl/mypolitics-app/issues/60)) +- `ModuleWrapper` - done ([#62](https://github.com/gi-org-pl/mypolitics-app/issues/62)) + +# Resources + +- [Spec - Horizontal bar chart](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/horizontal-bar-chart.md) - every case, in full +- [Docs - Horizontal bar chart](https://github.com/gi-org-pl/product/blob/main/mypolitics/docs/modules/quiz/results/modules/horizontal-bar-chart.md) - why the module exists +- [Figma - Horizontal bar chart frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-1414) - [flat, folded](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5508-23690) | [flat, open](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-1273) | [grouped](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-1355) | [category open](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-1661) | [examples](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5508-23836) +- [Front-end standards](https://github.com/Generacja-Innowacja/gi-tech-standards/tree/main/docs/frontend) +- [Storybook docs](https://storybook.js.org/docs/writing-stories) +- [Tailwind docs](https://tailwindcss.com/docs/) +- [Vitest docs](https://vitest.dev/guide/) + +# Definition of Done + +- [ ] Code follows folder structure (`docs/frontend/conventions/PROJECT_STRUCTURE.md`) +- [ ] Naming follows `docs/frontend/conventions/NAMING.md` +- [ ] Component layout follows `docs/frontend/conventions/COMPONENT_STRUCTURE.md` +- [ ] `RankedRow` is its own component, usable without `HorizontalBarChart` +- [ ] Sorting is a pure, stable util with its own tests; a badge and a comparison never move a row +- [ ] The flat list folds at `visibleRows` and opens to every row +- [ ] A category opens to show that category alone, in full, and returns to the list +- [ ] Open state is local `useState`, starts closed and is not saved +- [ ] Rows are an ordered list; controls are keyboard-operable and say whether the list is open; focus is never lost +- [ ] The body is made of `UniversalAxis` bars; nothing of the bar is re-implemented +- [ ] Invalid input degrades as in the table, without throwing +- [ ] Unit tests added, BDD style, coverage ≥95% on all new files +- [ ] Storybook stories added for all variants listed above +- [ ] Biome lint clean +- [ ] TypeScript clean (no `any`, no `@ts-ignore`) +- [ ] Every string from the Copy section goes through a Lingui macro, with Polish as the source; `yarn i18n:extract` run, English entries translated, `.po` files committed +- [ ] Branch named `feature/horizontal-bar-chart-69` +- [ ] CI green: build, lint, test diff --git a/mypolitics/tasks/epics/modules/cycle-1/results-header.md b/mypolitics/tasks/epics/modules/cycle-1/results-header.md new file mode 100644 index 0000000..22d37fe --- /dev/null +++ b/mypolitics/tasks/epics/modules/cycle-1/results-header.md @@ -0,0 +1,293 @@ +Results header - states + +# Story + +As a user who has just finished a quiz, I want the top of my result to tell me in one line which orientation I came out as and how confident that result is - or to say plainly that there is no match - and to let me switch between my result and comparison. + +# Component properties + +**Component:** `ResultsHeader` +**Location:** `src/components/results/modules/ResultsHeader/` +**Shared:** no - in the `results` domain. Not to be confused with the site header in `src/components/shared/Header/` + +It does **not** sit in a `ModuleWrapper` and has no statistics or info button. **No scoring**: it receives the leading orientation and its confidence. This task also creates the **match bands** as a shared util, because the `Archetype` module uses the same ones. + +```ts +export type ResultsTab = 'results' | 'comparison'; + +export interface ResultsHeaderLink { + url: string; + label?: string; +} + +export interface ResultsHeaderProps { + orientation?: { name: string; imageUrl?: string }; // absent = no match + confidence?: number; // 0-100 + slogan?: string; // author-written + link?: ResultsHeaderLink; // author-written + activeTab: ResultsTab; + onTabChange: (tab: ResultsTab) => void; +} + +// src/constants/results.ts +export const PARTIAL_MATCH_FROM = 50; +export const MATCH_FROM = 80; + +// src/utils/results/getMatchBand.ts +export type MatchBand = 'none' | 'partial' | 'match'; +export const getMatchBand = (value?: number): MatchBand => ... +``` + +# Behaviour + +The [spec](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/header.md) is the source of truth for every case below; the [Figma frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-3239) is the source of truth for sizes, spacing, type, icons and colours. + +### Match bands + +| Band | Range | +|---|---| +| No match | Below 50 | +| Partial match | From 50, below 80 | +| Match | 80 and above | + +Decided on the exact value, not the rounded one. Each band has its own palette colour, never the orientation's. + +**Figma draws "75%" in green. That is a slip in the drawing**: 75 is a partial match and takes the partial band's colour, as the band chart beside it shows. + +### Result + +| Case | Behaviour | +|---|---| +| Match or partial match | The orientation's image inside a ring, its name, and the confidence as a rounded percentage with its word, e.g. "75% pewności" | +| Ring | An arc as long as the confidence, in the band's colour | +| Confidence text | In the band's colour | +| No match | A question mark in place of the image, no ring, "Brak dopasowania" in place of the name, and no confidence | + +Below 50 the orientation's name and image are not shown, even though they were passed. The header never names the least bad option. + +### Extras + +| Case | Behaviour | +|---|---| +| Slogan present | A labelled chip. Not interactive | +| Link present | A button showing the label, which opens the address in a new tab | +| Neither | The row they share is not drawn | +| No match | Neither is drawn, whatever was passed | + +### Tabs + +| Case | Behaviour | +|---|---| +| Always | Two tabs of equal width under the result: "Twoje wyniki", then "Tryb porównania" | +| Active tab | Marked as selected | +| A tab pressed | `onTabChange` is called. The header is controlled: it waits for the new `activeTab` | + +### Layout + +| Case | Behaviour | +|---|---| +| Narrow | Everything stacks: result, then extras, then tabs | +| Wide | The extras move to the end of the result's row, slogan above link. Tabs stay underneath, across the full width | + +The content is the same in both. Which layout applies depends on the width the header is given, not on the viewport. + +### Invalid and edge input + +Nothing throws. The slogan and the link are someone else's words in the product's most trusted spot, so a malformed one is dropped rather than shown broken. + +| Input | Behaviour | +|---|---| +| Orientation absent | No match | +| Confidence absent or not a number | No match | +| Confidence outside 0-100 | Clamped | +| Orientation without an image | A neutral placeholder inside the ring | +| Name longer than the room | Wraps onto a second line, then is truncated; complete for assistive technology | +| Slogan or link label longer than the room | Truncated to one line; complete for assistive technology | +| Link with an empty label | The address is shown as the label | +| Link whose address is not `http:` or `https:` | The link is not drawn | +| Slogan with line breaks | Collapsed to one line | + +### Accessibility + +- The name is the main heading of the result screen. +- The ring is decorative. The confidence is carried by its text. +- A band is never carried by colour alone: no match changes the wording, and a partial match differs from a match by its number. +- The tabs are a tab list, operable from the keyboard, with the selected tab announced. +- The link says that it opens in a new tab, and is opened with `rel="noopener noreferrer"`. + +# Copy + +Polish is the source language, and every string is translatable. Each one below goes through a Lingui macro - `` for JSX text, `t` or `msg` for attributes and ARIA text - with no hardcoded literals. After adding them run `yarn i18n:extract`, fill in the English entries in `src/locales/en/messages.po`, and commit both `.po` files. The wording is a proposal: adjust it if needed, but keep the meaning. + +| Where | Polish (source) | English | +|---|---|---| +| Name, no match | Brak dopasowania | No match | +| Confidence | {value}% pewności | {value}% confidence | +| First tab | Twoje wyniki | Your results | +| Second tab | Tryb porównania | Comparison mode | +| Link, for assistive technology | {label} (otwiera się w nowej karcie) | {label} (opens in a new tab) | + +# Athena components to use + +- **`Tabs`** for the two tabs, if it can render two equal-width tabs as in Figma. If it cannot, say why in the PR. +- **`Avatar`** for the orientation's image inside the ring. +- **`Button`** for the link, rendered as an anchor. +- The confidence ring has no Athena equivalent. Draw it with SVG and Tailwind; no charting library. + +# Out of scope + +- Deciding which orientation leads and how confident the result is. +- What each tab shows - the header only reports the choice. +- The identity block of the generated result image. +- Moderating the slogan and the link. +- Wiring the header into a page. + +# Files to create + +``` +src/components/results/modules/ResultsHeader/ +├── ResultsHeader.tsx +├── ResultsHeader.test.tsx +├── ResultsHeader.types.ts +└── ResultsHeader.stories.tsx + +src/utils/results/ +├── getMatchBand.ts +└── getMatchBand.test.ts + +src/constants/results.ts # PARTIAL_MATCH_FROM, MATCH_FROM +``` + +No empty files - add a constants file or a subcomponent only if the view needs one. + +# Unit test cases (BDD) + +```ts +describe('getMatchBand()', () => { + describe('given a value below 50', () => { + it('returns none', ...); + }); + describe('given 50', () => { + it('returns partial', ...); + }); + describe('given 79.9', () => { + it('returns partial, on the exact value', ...); + }); + describe('given 80', () => { + it('returns match', ...); + }); + describe('given a value outside 0-100', () => { + it('clamps it first', ...); + }); + describe('given no value or a value that is not a number', () => { + it('returns none', ...); + }); +}); + +describe('', () => { + describe('given a match', () => { + it('renders the image, the name and the rounded confidence with its word', ...); + it('renders the name as the main heading', ...); + it('renders the ring and the confidence in the match colour', ...); + }); + describe('given a partial match', () => { + it('renders the ring and the confidence in the partial colour', ...); + }); + describe('given no match', () => { + it('renders the question mark and the no match wording', ...); + it('renders no ring and no confidence', ...); + it('does not render the orientation name or image', ...); + it('renders neither the slogan nor the link', ...); + }); + describe('given no orientation or no confidence', () => { + it('renders no match', ...); + }); + describe('given a slogan', () => { + it('renders it as a non-interactive chip', ...); + it('collapses line breaks into one line', ...); + }); + describe('given a link', () => { + it('renders a link that opens in a new tab and says so', ...); + it('shows the address when the label is empty', ...); + it('renders nothing when the address is not http or https', ...); + }); + describe('given neither slogan nor link', () => { + it('does not render the extras row', ...); + }); + describe('given an orientation without an image', () => { + it('renders a placeholder inside the ring', ...); + }); + describe('tabs', () => { + it('renders both tabs, results first', ...); + it('marks the active tab as selected', ...); + describe('when the other tab is pressed', () => { + it('calls onTabChange with that tab', ...); + it('does not switch until activeTab changes', ...); + }); + it('is operable from the keyboard', ...); + }); +}); +``` + +# Storybook stories + +**Figma, in the frame's order** +- `Standard` +- `WithExtras` - slogan and link +- `NoMatch` +- `Desktop` - the wide layout + +**Bands** +- `PartialMatch` - 75 +- `Match` - 80 +- `JustBelowPartial` - 49 + +**Edge** +- `SloganOnly`, `LinkOnly`, `InvalidLink`, `LinkWithoutLabel` +- `LongName`, `LongSlogan`, `NoImage` +- `ComparisonTabActive` + +# Remember about standards + +- Use the standard colors palette, never add colors directly (check https://tailwindcss.com/docs/colors and our color palette in the `src/index.css` file and in [athena](https://github.com/gi-org-pl/athena/blob/main/src/index.css)) +- Create unit tests with Vitest for 100% of the code created if feasible (check our [testing convention](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/TESTING_CONVENTION.md)) +- Create a Storybook story for the component with all possible props variants of the component +- Comply with [the component structure](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/COMPONENT_STRUCTURE.md) +- Name the branch `feature/results-header-68`, following [Conventional Branch](https://conventional-branch.github.io/) - never keep a generated or default branch name +- Copy is Polish by default and fully translatable: every literal string and every ARIA text goes through Lingui macros, and the English entries are filled in (see Copy). Names and values come from props +- The PR follows the repository's pull request template, with screenshots of the Figma stories next to the Figma frame + +# Dependencies + +- None. This task can start straight away. + +# Resources + +- [Spec - Header](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/header.md) - every case, in full +- [Docs - Header](https://github.com/gi-org-pl/product/blob/main/mypolitics/docs/modules/quiz/results/modules/header.md) - why the module exists +- [Figma - Header frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-3239) - [standard](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-56202) | [with slogan and link](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-56229) | [no match](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-56288) | [desktop](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-56171) | [the match bands](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-35810) +- [Front-end standards](https://github.com/Generacja-Innowacja/gi-tech-standards/tree/main/docs/frontend) +- [Storybook docs](https://storybook.js.org/docs/writing-stories) +- [Tailwind docs](https://tailwindcss.com/docs/) +- [Vitest docs](https://vitest.dev/guide/) + +# Definition of Done + +- [ ] Code follows folder structure (`docs/frontend/conventions/PROJECT_STRUCTURE.md`) +- [ ] Naming follows `docs/frontend/conventions/NAMING.md` +- [ ] Component layout follows `docs/frontend/conventions/COMPONENT_STRUCTURE.md` +- [ ] Bands come from `getMatchBand` and the constants in `src/constants/results.ts`, on the exact value +- [ ] 75 renders in the partial band's colour, not as Figma draws it +- [ ] No match shows the question mark and the wording, and hides the name, image, ring, confidence, slogan and link +- [ ] The link opens only `http:` and `https:` addresses, in a new tab, with `rel="noopener noreferrer"` +- [ ] The tabs are controlled through `activeTab` and `onTabChange` +- [ ] The layout follows the header's own width +- [ ] The name is the main heading; the tabs are a keyboard-operable tab list +- [ ] Invalid input degrades as in the table, without throwing +- [ ] Unit tests added, BDD style, coverage ≥95% on all new files +- [ ] Storybook stories added for all variants listed above +- [ ] Biome lint clean +- [ ] TypeScript clean (no `any`, no `@ts-ignore`) +- [ ] Every string from the Copy section goes through a Lingui macro, with Polish as the source; `yarn i18n:extract` run, English entries translated, `.po` files committed +- [ ] Branch named `feature/results-header-68` +- [ ] CI green: build, lint, test diff --git a/mypolitics/tasks/epics/modules/cycle-1/single-axis-chart.md b/mypolitics/tasks/epics/modules/cycle-1/single-axis-chart.md new file mode 100644 index 0000000..33cc1e2 --- /dev/null +++ b/mypolitics/tasks/epics/modules/cycle-1/single-axis-chart.md @@ -0,0 +1,209 @@ +Single axis chart - states and examples + +# Story + +As a user reading my quiz result, I want one orientation shown as one card - its name and icon in the title, and a single bar from zero to my score - so that I can read how strongly my answers lean that way without comparing it to anything else on the screen. + +# Component properties + +**Component:** `SingleAxisChart` +**Location:** `src/components/results/modules/SingleAxisChart/` +**Shared:** no - a result module, in the `results` domain + +A `ModuleWrapper` whose title is the orientation and whose body is one one-sided `UniversalAxis`. **No state and no scoring**: it draws the value it is given. + +```ts +export interface SingleAxisChartProps { + orientation: AxisOrientation; // from UniversalAxis.types + value?: number; // 0-100; absent = the quiz has no answers behind this orientation + marker?: number | false; // default 50 + comparison?: AxisEntry; // the other party, passed to the bar unchanged + onStatsClick?: () => void; // passed to ModuleWrapper + onInfoClick?: () => void; // passed to ModuleWrapper +} +``` + +# Behaviour + +The [spec](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/single-axis-chart.md) is the source of truth for every case below; the [Figma frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5508-22154) is the source of truth for sizes, spacing, type, icons and colours. + +### Title + +The title is a chip with the orientation's icon and name, passed to `ModuleWrapper` as a component title. + +| Case | Behaviour | +|---|---| +| Value at or above the marker position | Emphasised: the chip is filled with the orientation's colour | +| Value below the marker position | Quiet: the chip is outlined and its content muted | +| `marker={false}` | The emphasis line stays at 50 | +| Value absent | Quiet | +| Orientation without an image | The chip shows the name alone | + +The chip is never interactive. + +### Bar + +| Case | Behaviour | +|---|---| +| Value present | `UniversalAxis` with a `start` entry only | +| Value is zero | The cap and an unfilled track, no number - `UniversalAxis` does this | +| Value absent | `UniversalAxis` with no entry: an empty track with no cap | +| Comparison present | Passed to the bar unchanged | + +`showLabels` stays off: the title already names the orientation. Do not re-implement anything the bar does - value placement, rounding, clamping, the marker and the comparison band are all `UniversalAxis`. + +### Invalid and edge input + +| Input | Behaviour | +|---|---| +| Value outside 0-100 | Clamped by the bar. The title's emphasis uses the clamped value | +| Value not a number | Treated as absent | +| Name missing or empty | The chip shows the image alone. With neither, the wrapper gets no title | +| Name longer than the title slot | Truncated by the wrapper, complete for assistive technology | +| Orientation without a colour | The neutral fallback, on the chip and on the bar | +| Marker outside 0-100 | Clamped; the emphasis line moves with it | + +### Accessibility + +- Pass the orientation's name to `ModuleWrapper` as `ariaLabel`, since the title is a component. +- Emphasis is never the only signal: the number on the bar says the same thing. + +# Copy + +This task adds no literal copy of its own: every name and value it shows comes from props. If you do add a string, including an ARIA text, it follows the same rule as everywhere in the app - Polish source, through a Lingui macro, with the English entry filled in. + +# Athena components to use + +- `ModuleWrapper` and `UniversalAxis` from `src/components/shared/` - do not rebuild either. +- No Athena component fits the title chip. Build `OrientationChip` in `src/components/results/modules/OrientationChip/` with three looks: emphasised, quiet and neutral. **The `DoubleAxisChart` task needs the same chip**: create it if it does not exist yet, reuse and extend it if it does. +- Orientation colours are author-supplied data, not design tokens. Apply them at runtime through a CSS custom property, exactly as `UniversalAxis` does (approved in [mypolitics-app#61](https://github.com/gi-org-pl/mypolitics-app/pull/61#issuecomment-6007060837)), and accept only values that pass `SAFE_COLOR_PATTERN`. That check lives in `UniversalAxis.constants.ts` today: promote it to a shared place instead of copying it. Every other colour comes from the palette. + +# Out of scope + +- Anything the bar draws - it is `UniversalAxis`. +- The card frame and its two buttons - `ModuleWrapper`. +- Deciding which orientation the module shows, and hiding the module. + +# Files to create + +``` +src/components/results/modules/SingleAxisChart/ +├── SingleAxisChart.tsx +├── SingleAxisChart.test.tsx +├── SingleAxisChart.types.ts +└── SingleAxisChart.stories.tsx + +src/components/results/modules/OrientationChip/ # only if it does not exist yet +├── OrientationChip.tsx +├── OrientationChip.test.tsx +├── OrientationChip.types.ts +└── OrientationChip.stories.tsx +``` + +No empty files - add a constants file or a subcomponent only if the view needs one. + +# Unit test cases (BDD) + +```ts +describe('', () => { + describe('given a value at or above the marker', () => { + it('renders the title chip emphasised', ...); + }); + describe('given a value below the marker', () => { + it('renders the title chip quiet', ...); + }); + describe('given marker is false', () => { + it('keeps the emphasis line at 50', ...); + it('renders the bar without a marker', ...); + }); + describe('given a custom marker', () => { + it('moves the emphasis line with it', ...); + }); + describe('given a value', () => { + it('renders a one-sided bar from the start cap', ...); + it('renders the bar without labels', ...); + }); + describe('given no value', () => { + it('renders an empty track and a quiet title', ...); + }); + describe('given a value that is not a number', () => { + it('treats it as absent', ...); + }); + describe('given a value outside 0-100', () => { + it('uses the clamped value for the title emphasis', ...); + }); + describe('given a comparison', () => { + it('passes it to the bar', ...); + }); + describe('given an orientation without an image', () => { + it('renders the chip with the name alone', ...); + }); + describe('given an orientation without a name', () => { + it('renders the chip with the image alone', ...); + it('passes no title when there is no image either', ...); + }); + describe('given handlers', () => { + it('passes onStatsClick and onInfoClick to the wrapper', ...); + }); + describe('accessibility', () => { + it('names the card after the orientation', ...); + }); +}); +``` + +# Storybook stories + +**Figma, in the frame's order** +- `Standard` - placeholder orientation at 40, quiet title +- `ExampleQuiet` - 40, below the marker +- `ExampleEmphasised` - 69, above the marker +- `Comparison` + +**Edge** +- `ZeroValue`, `NoValue` +- `NoMarker`, `CustomMarker` +- `NoImage`, `NoColor`, `LongName` + +# Remember about standards + +- Use the standard colors palette, never add colors directly (check https://tailwindcss.com/docs/colors and our color palette in the `src/index.css` file and in [athena](https://github.com/gi-org-pl/athena/blob/main/src/index.css)) +- Create unit tests with Vitest for 100% of the code created if feasible (check our [testing convention](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/TESTING_CONVENTION.md)) +- Create a Storybook story for the component with all possible props variants of the component +- Comply with [the component structure](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/COMPONENT_STRUCTURE.md) +- Name the branch `feature/single-axis-chart-65`, following [Conventional Branch](https://conventional-branch.github.io/) - never keep a generated or default branch name +- Copy is Polish by default and fully translatable: every literal string and every ARIA text goes through Lingui macros, and the English entries are filled in (see Copy). Names and values come from props +- The PR follows the repository's pull request template, with screenshots of the Figma stories next to the Figma frame + +# Dependencies + +- `UniversalAxis` - done ([#60](https://github.com/gi-org-pl/mypolitics-app/issues/60)) +- `ModuleWrapper` - done ([#62](https://github.com/gi-org-pl/mypolitics-app/issues/62)) + +# Resources + +- [Spec - Single axis chart](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/single-axis-chart.md) - every case, in full +- [Docs - Single axis chart](https://github.com/gi-org-pl/product/blob/main/mypolitics/docs/modules/quiz/results/modules/single-axis-chart.md) - why the module exists +- [Figma - Single axis chart frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5508-22154) - [standard](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5508-22299) | [example, below the marker](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5508-22161) | [example, above the marker](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5508-22269) | [comparison](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-39163) +- [Front-end standards](https://github.com/Generacja-Innowacja/gi-tech-standards/tree/main/docs/frontend) +- [Storybook docs](https://storybook.js.org/docs/writing-stories) +- [Tailwind docs](https://tailwindcss.com/docs/) +- [Vitest docs](https://vitest.dev/guide/) + +# Definition of Done + +- [ ] Code follows folder structure (`docs/frontend/conventions/PROJECT_STRUCTURE.md`) +- [ ] Naming follows `docs/frontend/conventions/NAMING.md` +- [ ] Component layout follows `docs/frontend/conventions/COMPONENT_STRUCTURE.md` +- [ ] The title chip is emphasised at or above the marker position and quiet below it +- [ ] The body is one one-sided `UniversalAxis`; nothing of the bar is re-implemented +- [ ] No value renders an empty track, not an error and not a missing card +- [ ] The author-supplied colour is applied only through a CSS custom property and validated +- [ ] `OrientationChip` exists once and is shared with `DoubleAxisChart` +- [ ] Invalid input degrades as in the table, without throwing +- [ ] Unit tests added, BDD style, coverage ≥95% on all new files +- [ ] Storybook stories added for all variants listed above +- [ ] Biome lint clean +- [ ] TypeScript clean (no `any`, no `@ts-ignore`) +- [ ] Every string from the Copy section goes through a Lingui macro, with Polish as the source; `yarn i18n:extract` run, English entries translated, `.po` files committed +- [ ] Branch named `feature/single-axis-chart-65` +- [ ] CI green: build, lint, test diff --git a/mypolitics/tasks/epics/modules/cycle-1/traits.md b/mypolitics/tasks/epics/modules/cycle-1/traits.md new file mode 100644 index 0000000..7e4271a --- /dev/null +++ b/mypolitics/tasks/epics/modules/cycle-1/traits.md @@ -0,0 +1,243 @@ +Traits - alone and in comparison + +# Story + +As a user reading my quiz result, I want the traits I earned shown as a set of badges, and in comparison I want to see which of them my friend shares and which only they have, so that I can tell at a glance what we have in common. + +# Component properties + +**Component:** `Traits` +**Location:** `src/components/results/modules/Traits/` +**Shared:** no - a result module, in the `results` domain + +A `ModuleWrapper` around a set of pills, one per earned trait. The only result module with no bar: a trait is held or not held. **No state, and it does not decide who earned what.** + +```ts +export interface TraitsComparison { + party: AxisOrientation; // the other party; imageUrl is their avatar + earnedIds: string[]; // ids of the traits they earned +} + +export interface TraitsProps { + title?: string; + traits: AxisOrientation[]; // every trait the quiz defines, in the quiz's order; imageUrl is the trait's icon + earnedIds: string[]; // ids of the traits the taker earned + comparison?: TraitsComparison; // its presence turns comparison on + onStatsClick?: () => void; + onInfoClick?: () => void; +} +``` + +**Why this shape.** The quiz's traits are configuration and who earned what is a result, so they arrive separately: the definitions once, in the quiz's order, and each party's result as a list of ids. That keeps the order in one place, makes the taker and the other party symmetrical, and means adding a comparison is adding one object rather than rewriting the list. Two lists of earned traits would not work - they cannot be merged back into the quiz's order - and one list with per-trait flags mixes configuration with results. + +A trait nobody earned is not drawn. + +# Behaviour + +The [spec](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/traits.md) is the source of truth for every case below; the [Figma frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-26739) is the source of truth for sizes, spacing, type, icons and colours. + +### Pills + +| Case | Behaviour | +|---|---| +| A trait | One pill: the trait's icon, then its name, on the trait's colour | +| Several traits | Pills flow in a row and wrap onto further rows, in the order given | +| A trait without an icon | The pill shows the name alone | +| A pill pressed | Nothing happens. Pills are not interactive | + +The order means nothing. Never sort, number or emphasise one pill over another, and never resize pills to fit. + +### Comparison + +With `comparison`, the set drawn is every trait either party earned, in the order of `traits`. + +| Case | Behaviour | +|---|---| +| Both earned it | A solid pill carrying the other party's avatar on its corner | +| Only the taker earned it | A solid pill with no avatar | +| Only the other party earned it | A hatched pill carrying the other party's avatar | +| The other party earned nothing | The taker's pills, all without an avatar | + +The hatching is the same pattern `UniversalAxis` uses for comparison. If that pattern is not reusable as it stands, extract it to a shared place; do not draw a second one. + +### Empty + +| Case | Behaviour | +|---|---| +| No traits, no comparison | The card is drawn with one line of text saying no traits were earned | +| No traits for either party | The same line | +| The taker earned none, the other party did | The other party's pills, all hatched | + +Figma does not draw the empty state. Use ordinary body text; the wording is in Copy. + +### Invalid and edge input + +| Input | Behaviour | +|---|---| +| Name longer than the card | The pill is as wide as the card at most; the name is truncated to one line, complete for assistive technology | +| Trait without a name | Not drawn | +| Trait without a colour | The neutral fallback colour | +| Trait with a light colour | The label stays readable: dark text on a light pill, light text on a dark one | +| The same trait listed twice | Drawn once | +| Other party without an avatar | A neutral avatar placeholder in the same place | +| `comparison` with an empty or missing `earnedIds` | Treated as the other party having earned nothing | +| An id in either `earnedIds` that no trait has | Ignored | +| Trait neither party earned | Not drawn | + +### Accessibility + +- The pills are a list, and each item is its trait's name. +- In comparison each item also says whose it is in words - shared with the other party, or only theirs. Hatching and the avatar are never the only carriers. +- The trait icon is decorative. + +# Copy + +Polish is the source language, and every string is translatable. Each one below goes through a Lingui macro - `` for JSX text, `t` or `msg` for attributes and ARIA text - with no hardcoded literals. After adding them run `yarn i18n:extract`, fill in the English entries in `src/locales/en/messages.po`, and commit both `.po` files. The wording is a proposal: adjust it if needed, but keep the meaning. + +| Where | Polish (source) | English | +|---|---|---| +| The empty line | Brak zdobytych cech | No traits earned | +| Item name for assistive technology, shared | {trait} - wspólna z: {name} | {trait} - shared with {name} | +| Item name for assistive technology, only theirs | {trait} - tylko {name} | {trait} - only {name} | + +# Athena components to use + +- `ModuleWrapper` from `src/components/shared/`. +- **`Avatar`** for the other party's image on a pill. +- Do **not** use Athena's `Badge` for the pill unless it can take a runtime colour, an icon and the hatched look; if it cannot, build the pill with Tailwind and say so in the PR. +- Orientation colours are author-supplied data, not design tokens. Apply them at runtime through a CSS custom property, exactly as `UniversalAxis` does (approved in [mypolitics-app#61](https://github.com/gi-org-pl/mypolitics-app/pull/61#issuecomment-6007060837)), and accept only values that pass `SAFE_COLOR_PATTERN`. That check lives in `UniversalAxis.constants.ts` today: promote it to a shared place instead of copying it. Every other colour comes from the palette. + +# Out of scope + +- Deciding whether a trait is earned. +- The mid-quiz card that announces a trait. +- Moderating trait names. +- Hiding the module for a quiz with no traits configured. + +# Files to create + +``` +src/components/results/modules/Traits/ +├── Traits.tsx +├── Traits.test.tsx +├── Traits.types.ts +├── Traits.stories.tsx +├── TraitPill/ # the pill, with its solid and hatched looks +│ ├── TraitPill.tsx +│ └── TraitPill.test.tsx +└── utils/ + ├── getTraitItems.ts # pure: definitions and both id lists in; the drawn set with who holds each out + └── getTraitItems.test.ts +``` + +Keep the merging in the pure util so the comparison cases are tested without rendering. No empty files. + +# Unit test cases (BDD) + +```ts +describe('getTraitItems()', () => { + describe('given no comparison', () => { + it('returns the traits the taker earned, in the order of the definitions', ...); + it('ignores an earned id that no trait has', ...); + it('drops a trait without a name', ...); + it('returns a trait defined twice once', ...); + }); + describe('given a comparison', () => { + it('marks a trait both earned as shared', ...); + it('marks a trait only the taker earned as the taker own', ...); + it('marks a trait only the other party earned as theirs', ...); + it('drops a trait neither earned', ...); + it('keeps the order of the definitions', ...); + it('returns only theirs when the taker earned none', ...); + it('treats missing earnedIds as nothing earned', ...); + }); +}); + +describe('', () => { + describe('given traits', () => { + it('renders one pill per trait, with its icon and name', ...); + it('renders the pills as a list', ...); + it('renders a pill without an icon with the name alone', ...); + it('does not make the pills interactive', ...); + }); + describe('given a comparison', () => { + it('renders a shared trait solid, with the other party avatar', ...); + it('renders a trait only the other party earned hatched, with their avatar', ...); + it('renders a trait only the taker earned solid, with no avatar', ...); + it('says in words whose each trait is', ...); + it('renders a placeholder when the other party has no avatar', ...); + }); + describe('given no traits', () => { + it('renders the empty line', ...); + it('renders the other party pills hatched when only they earned traits', ...); + }); + describe('given a trait without a colour', () => { + it('uses the neutral colour', ...); + }); + describe('given a trait with a light colour', () => { + it('renders the label in a dark tone', ...); + }); + describe('given handlers', () => { + it('passes onStatsClick and onInfoClick to the wrapper', ...); + }); +}); +``` + +# Storybook stories + +**Figma, in the frame's order** +- `Standard` - placeholder traits +- `Example` +- `Comparison` - shared, the taker's own and only-theirs together + +**Edge** +- `Empty`, `EmptyForBoth`, `OnlyTheirs` +- `ManyTraits` - wrapping onto several rows +- `LongName`, `NoIcon`, `NoColor`, `LightColor` +- `ComparisonWithoutAvatar` + +# Remember about standards + +- Use the standard colors palette, never add colors directly (check https://tailwindcss.com/docs/colors and our color palette in the `src/index.css` file and in [athena](https://github.com/gi-org-pl/athena/blob/main/src/index.css)) +- Create unit tests with Vitest for 100% of the code created if feasible (check our [testing convention](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/TESTING_CONVENTION.md)) +- Create a Storybook story for the component with all possible props variants of the component +- Comply with [the component structure](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/COMPONENT_STRUCTURE.md) +- Name the branch `feature/traits-67`, following [Conventional Branch](https://conventional-branch.github.io/) - never keep a generated or default branch name +- Copy is Polish by default and fully translatable: every literal string and every ARIA text goes through Lingui macros, and the English entries are filled in (see Copy). Names and values come from props +- The PR follows the repository's pull request template, with screenshots of the Figma stories next to the Figma frame + +# Dependencies + +- `ModuleWrapper` - done ([#62](https://github.com/gi-org-pl/mypolitics-app/issues/62)) + +# Resources + +- [Spec - Traits](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/traits.md) - every case, in full +- [Docs - Traits](https://github.com/gi-org-pl/product/blob/main/mypolitics/docs/modules/quiz/results/modules/traits.md) - why the module exists +- [Figma - Traits frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-26739) - [standard](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-28051) | [example](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-27851) | [comparison](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-40488) +- [Front-end standards](https://github.com/Generacja-Innowacja/gi-tech-standards/tree/main/docs/frontend) +- [Storybook docs](https://storybook.js.org/docs/writing-stories) +- [Tailwind docs](https://tailwindcss.com/docs/) +- [Vitest docs](https://vitest.dev/guide/) + +# Definition of Done + +- [ ] Code follows folder structure (`docs/frontend/conventions/PROJECT_STRUCTURE.md`) +- [ ] Naming follows `docs/frontend/conventions/NAMING.md` +- [ ] Component layout follows `docs/frontend/conventions/COMPONENT_STRUCTURE.md` +- [ ] Pills render in the order given, wrap, and are never sorted or resized +- [ ] Comparison renders the three kinds: shared, the taker's own, only theirs +- [ ] The hatching is the same pattern `UniversalAxis` uses, not a second one +- [ ] An empty set renders the empty line instead of a blank body +- [ ] Labels stay readable on any author colour +- [ ] The author-supplied colour is applied only through a CSS custom property and validated +- [ ] Each item says in words whose it is in comparison +- [ ] Props are the quiz's trait definitions plus each party's earned ids; the order comes from the definitions only +- [ ] Invalid input degrades as in the table, without throwing +- [ ] Unit tests added, BDD style, coverage ≥95% on all new files +- [ ] Storybook stories added for all variants listed above +- [ ] Biome lint clean +- [ ] TypeScript clean (no `any`, no `@ts-ignore`) +- [ ] Every string from the Copy section goes through a Lingui macro, with Polish as the source; `yarn i18n:extract` run, English entries translated, `.po` files committed +- [ ] Branch named `feature/traits-67` +- [ ] CI green: build, lint, test diff --git a/mypolitics/tasks/epics/modules/cycle-1/universal-axis-absent-value.md b/mypolitics/tasks/epics/modules/cycle-1/universal-axis-absent-value.md new file mode 100644 index 0000000..8b78f11 --- /dev/null +++ b/mypolitics/tasks/epics/modules/cycle-1/universal-axis-absent-value.md @@ -0,0 +1,194 @@ +Universal axis - one-sided and double-sided, with every state + +# Story + +As a user reading my quiz result, I want an orientation the quiz knows about but has no answers for to still be shown on its bar - its icon on the cap and its name under it, with nothing filled - so that a missing score reads as an absence and not as a different chart. + +# Component properties + +**Component:** `UniversalAxis` +**Location:** `src/components/shared/UniversalAxis/` - it exists; this task changes it +**Shared:** yes + +Today an entry whose value is missing is treated as if the entry were not passed at all: its cap disappears, and a double-sided bar with one unknown side collapses into a one-sided bar. The modules built on the bar need to tell two things apart: + +- **No entry** - there is no orientation on that side. +- **An entry without a value** - the orientation is known, the score is not. + +```ts +export interface AxisEntry { + orientation: AxisOrientation; + value?: number; // was required. Absent or not a number = the orientation is known, the score is not +} +``` + +Nothing else in the props changes, and every bar that passes a number renders exactly as before. + +# Behaviour + +The [spec](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/universal-axis.md) is the source of truth for every case below; the [Figma frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-39733) is the source of truth for sizes, spacing, type, icons and colours. + +### Entry without a value + +| Case | Behaviour | +|---|---| +| `start` or `end` passed with no value | The entry counts as present: its cap is drawn, and its label when labels are on. It has no fill and no number | +| The same, with a value that is not a number | The same | +| Double-sided bar, one entry without a value | Still double-sided: both caps and both labels. The other side is drawn as usual | +| Double-sided bar, both entries without a value | Both caps and both labels on an unfilled track | +| One-sided bar, its entry without a value | The cap on an unfilled track | +| No entry on a side | Unchanged: no cap for that side | +| Neither entry | Unchanged: the empty track | + +An entry without a value draws exactly like an entry with a value of zero. The difference is only in what is announced. + +### Mode + +The mode follows which entries are **passed**, not which have values: no entries is empty, one is one-sided, two is double-sided. + +### Comparison + +| Case | Behaviour | +|---|---| +| The taker's entry has no value | As when the taker has no entry today: the whole track is hatched and only the other party's image is positioned | +| `comparison` passed with no value | Unchanged: no comparison is drawn | + +### Accessibility + +The image description names an entry without a value and says its value is missing, instead of leaving the orientation out or reading it as zero. + +### What must not change + +- Every existing story and test that passes numbers keeps its output. +- Clamping, scaling above 100, rounding, the fit thresholds, the marker and the draw order. +- No measuring, no animation, no new props. + +# Copy + +Polish is the source language, and every string is translatable. Each one below goes through a Lingui macro - `` for JSX text, `t` or `msg` for attributes and ARIA text - with no hardcoded literals. After adding them run `yarn i18n:extract`, fill in the English entries in `src/locales/en/messages.po`, and commit both `.po` files. The wording is a proposal: adjust it if needed, but keep the meaning. + +| Where | Polish (source) | English | +|---|---|---| +| Image description, an entry without a value | {name}: brak wyniku | {name}: no result | + +# Athena components to use + +- Nothing new. The change is inside `UniversalAxis` and its pure `utils/getAxisLayout.ts`. + +# Out of scope + +- The modules that will rely on this - they are separate tasks. +- Any other change to how the bar draws. + +# Files to create + +``` +src/components/shared/UniversalAxis/ +├── UniversalAxis.tsx # changed only if the view needs it +├── UniversalAxis.test.tsx # new cases +├── UniversalAxis.types.ts # AxisEntry.value becomes optional +├── UniversalAxis.stories.tsx # new stories +└── utils/ + ├── getAxisLayout.ts # the change itself + └── getAxisLayout.test.ts # new cases +``` + +No new files. Keep the change in the pure util, where the existing cases already live. + +# Unit test cases (BDD) + +```ts +describe('getAxisLayout()', () => { + describe('given a start entry without a value', () => { + it('returns one-sided mode with the start side present', ...); + it('gives that side no fill and a hidden value', ...); + }); + describe('given both entries, one without a value', () => { + it('returns double-sided mode', ...); + it('keeps the side without a value, with no fill and a hidden value', ...); + it('lays the other side out as usual', ...); + }); + describe('given both entries without a value', () => { + it('returns double-sided mode with both sides present and unfilled', ...); + }); + describe('given a value that is not a number', () => { + it('treats the entry as present and without a value', ...); + }); + describe('given a comparison and a taker entry without a value', () => { + it('hatches the whole track and positions only the other party image', ...); + }); + describe('given a comparison without a value', () => { + it('returns no comparison', ...); + }); + describe('given entries with numbers', () => { + it('returns the same layout as before for every existing case', ...); + }); +}); + +describe('', () => { + describe('given an entry without a value', () => { + it('renders its cap', ...); + it('renders its label when labels are on', ...); + it('renders no fill and no number for it', ...); + it('says in the description that its value is missing', ...); + }); + describe('given a double-sided bar with one entry without a value', () => { + it('renders both caps', ...); + }); +}); +``` + +# Storybook stories + +Add to the existing stories, next to the states they belong with: + +- `OneSidedNoValue` - one entry, known orientation, no value +- `DoubleSidedOneNoValue` - one side without a value +- `DoubleSidedBothNoValue` +- `DoubleSidedOneNoValueWithLabels` +- `ComparisonTakerNoValue` + +Every existing story stays as it is. + +# Remember about standards + +- Use the standard colors palette, never add colors directly (check https://tailwindcss.com/docs/colors and our color palette in the `src/index.css` file and in [athena](https://github.com/gi-org-pl/athena/blob/main/src/index.css)) +- Create unit tests with Vitest for 100% of the code created if feasible (check our [testing convention](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/TESTING_CONVENTION.md)) +- Create a Storybook story for the component with all possible props variants of the component +- Comply with [the component structure](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/COMPONENT_STRUCTURE.md) +- Name the branch `bugfix/universal-axis-absent-value-73`, following [Conventional Branch](https://conventional-branch.github.io/) - never keep a generated or default branch name +- Copy is Polish by default and fully translatable: every literal string and every ARIA text goes through Lingui macros, and the English entries are filled in (see Copy). Names and values come from props +- The PR follows the repository's pull request template, with screenshots of the Figma stories next to the Figma frame + +# Dependencies + +- None. This task can start straight away. + +# Resources + +- [Spec - Universal axis](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/universal-axis.md) - every case, in full +- [Docs - Universal axis](https://github.com/gi-org-pl/product/blob/main/mypolitics/docs/modules/quiz/results/modules/universal-axis.md) - why the module exists +- [Figma - Universal axis frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-39733) - [one-sided states](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-39736) | [double-sided states](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-40582) +- [Front-end standards](https://github.com/Generacja-Innowacja/gi-tech-standards/tree/main/docs/frontend) +- [Storybook docs](https://storybook.js.org/docs/writing-stories) +- [Tailwind docs](https://tailwindcss.com/docs/) +- [Vitest docs](https://vitest.dev/guide/) + +# Definition of Done + +- [ ] Code follows folder structure (`docs/frontend/conventions/PROJECT_STRUCTURE.md`) +- [ ] Naming follows `docs/frontend/conventions/NAMING.md` +- [ ] Component layout follows `docs/frontend/conventions/COMPONENT_STRUCTURE.md` +- [ ] `AxisEntry.value` is optional; an entry without a value keeps its cap and label and has no fill and no number +- [ ] The mode follows which entries are passed, not which have values +- [ ] Every existing test passes unchanged; no existing story changes its output +- [ ] The image description says an entry's value is missing +- [ ] The change lives in `getAxisLayout`; nothing is measured and no prop is added +- [ ] Invalid input degrades as in the table, without throwing +- [ ] Unit tests added, BDD style, coverage ≥95% on all new files +- [ ] Storybook stories added for all variants listed above +- [ ] Biome lint clean +- [ ] TypeScript clean (no `any`, no `@ts-ignore`) +- [ ] Every string from the Copy section goes through a Lingui macro, with Polish as the source; `yarn i18n:extract` run, English entries translated, `.po` files committed +- [ ] Branch named `bugfix/universal-axis-absent-value-73` +- [ ] CI green: build, lint, test diff --git a/mypolitics/tasks/epics/modules/cycle-2/archetype.md b/mypolitics/tasks/epics/modules/cycle-2/archetype.md new file mode 100644 index 0000000..85c22e3 --- /dev/null +++ b/mypolitics/tasks/epics/modules/cycle-2/archetype.md @@ -0,0 +1,315 @@ +Archetype - from no match to the full ranking + +# Story + +As a user reading my quiz result, I want to see the archetype I came out closest to, how strong that match is and what it means - or to be told plainly that nothing matched - and I want the full description and the other archetypes one tap away. + +# Component properties + +**Component:** `Archetype` +**Location:** `src/components/results/modules/Archetype/` +**Shared:** no - a result module, in the `results` domain + +A `ModuleWrapper` around the leading archetype: its name, its match on a one-sided `UniversalAxis`, and a description, with two controls that open the full description and the ranking. It picks the leader from what it is given and remembers what the taker opened; **no scoring**. + +```ts +export interface ArchetypeEntry { + orientation: AxisOrientation; // from UniversalAxis.types + match?: number; // 0-100 + shortDescription?: string; // author-written + fullDescription?: string; // author-written +} + +export interface ArchetypeProps { + title?: string; + archetypes: ArchetypeEntry[]; + comparison?: RankedComparison; // from the HorizontalBarChart task: the other party and their match by orientation id + onStatsClick?: () => void; + onInfoClick?: () => void; +} +``` + +Which view is open is local state (`useState`). It is not saved anywhere. + +# Behaviour + +The [spec](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/archetype.md) is the source of truth for every case below; the [Figma frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-28187) is the source of truth for sizes, spacing, type, icons and colours. + +### Leader + +The leader is the archetype with the highest match. With equal matches, the first in the order given leads. + +| Case | Behaviour | +|---|---| +| The leader is a match or a partial match | Its name, and a one-sided bar with its image on the cap | +| The leader is no match | "Brak dopasowania", quiet, in place of the name, and an empty track with no cap | +| The bar | `marker={false}` and no labels | +| The bar's colour | The colour of the match's band, **never the archetype's own colour** | + +Bands come from `getMatchBand`: below 50 no match, from 50 partial, 80 and above a match. + +### Band colours + +`UniversalAxis` stays completely universal: it takes a bar's colour from `orientation.color` and knows nothing about bands. So this module **overrides the orientation it passes to the bar**, replacing its colour with the band's, looked up in a map: + +```ts +// src/constants/results.ts +export const MATCH_BAND_COLORS: Record = { + none: '...', + partial: '...', + match: '...', +}; + +// for the leader's bar and for every ranked row +const entry = { + orientation: { ...archetype.orientation, color: MATCH_BAND_COLORS[getMatchBand(archetype.match)] }, + value: archetype.match, +}; +``` + +The three values are the palette's own values for the band colours in Figma, written as hex so they pass the bar's colour check. The map is the only place they are written out; do not put a colour literal anywhere else, and do not change `UniversalAxis`. This approach was decided by the Technical Leader. + +### Views + +The body under the leader shows one of three views. + +| View | Content | +|---|---| +| Summary | The leader's short description | +| Full description | The leader's full description | +| Ranking | Every other archetype as a `RankedRow`, highest match first, each bar in the colour of its own band | + +The ranking is never cut. + +### Controls + +Two controls sit at the foot of the card, description first, ranking second. + +| Case | Behaviour | +|---|---| +| Description control pressed | Opens the full description. Pressed again, returns to the summary | +| Ranking control pressed | Opens the ranking. Pressed again, returns to the summary | +| One view open and the other control pressed | Switches straight to the other view | +| The open view's control | Marked as active | +| Ranking control, closed | Previews the first few images from the ranking | +| Leader has no full description | No description control | +| No other archetypes | No ranking control | +| Neither control applies | No foot at all | + +### No match + +| Case | Behaviour | +|---|---| +| Summary | No description | +| Description control | Not shown | +| Ranking | Shows every archetype, the leader included | + +### Comparison + +| Case | Behaviour | +|---|---| +| Comparison value for an archetype | Passed to that archetype's bar, on the leader and in the ranking | +| No comparison value for an archetype | That bar is drawn without an overlay | +| Comparison present | The leader and the order of the ranking do not change | + +### Descriptions are plain text + +Paragraph breaks are kept. Markup, links and anything else in the text are shown as written and never interpreted - no `dangerouslySetInnerHTML`, no markdown. + +### Invalid and edge input + +| Input | Behaviour | +|---|---| +| No archetypes | An empty card under its title | +| Match absent or not a number | Treated as zero, and listed last | +| Match outside 0-100 | Clamped | +| Short description missing, full description present | The summary has no text; the description control still opens the full one | +| Full description identical to the short one | No description control | +| Description that is only whitespace | Treated as missing | +| Archetype without an image | Its cap is drawn with colour only, and it is left out of the ranking control's preview | +| Name longer than the row | Truncated; complete for assistive technology | +| Comparison value for an archetype not in the list | Ignored | + +### Accessibility + +- The leader's name is a heading inside the card. +- A band is never carried by colour alone: every bar has its number, and no match changes the wording. +- The two controls are icon buttons with text names. Each says whether its view is open, and works from the keyboard. +- The ranking is an ordered list. +- When a view opens, focus stays on the control that opened it. + +# Copy + +Polish is the source language, and every string is translatable. Each one below goes through a Lingui macro - `` for JSX text, `t` or `msg` for attributes and ARIA text - with no hardcoded literals. After adding them run `yarn i18n:extract`, fill in the English entries in `src/locales/en/messages.po`, and commit both `.po` files. The wording is a proposal: adjust it if needed, but keep the meaning. + +| Where | Polish (source) | English | +|---|---|---| +| Name, no match | Brak dopasowania | No match | +| Description control | Pełny opis | Full description | +| Ranking control | Ranking | Ranking | + +# Athena components to use + +- `ModuleWrapper` and `UniversalAxis` from `src/components/shared/`, and `RankedRow` from the `HorizontalBarChart` task - do not rebuild any of them. +- `getMatchBand` from the `ResultsHeader` task - do not write a second set of thresholds. +- **`Button`** with `isIconButton` for the two controls, if it can match the controls in Figma. +- **`Avatar`** for the small images in the ranking control's preview. + +# Out of scope + +- Anything the bar draws - it is `UniversalAxis`. +- Scoring archetypes. +- Moderating descriptions. +- The short results card, which reads the same leader and match. +- Saving which view the taker opened. + +# Files to create + +``` +src/components/results/modules/Archetype/ +├── Archetype.tsx +├── Archetype.test.tsx +├── Archetype.types.ts +├── Archetype.stories.tsx +└── utils/ + ├── getArchetypeRanking.ts # pure: entries in, leader and the sorted rest out + └── getArchetypeRanking.test.ts +``` + +`MATCH_BAND_COLORS` is added to the existing `src/constants/results.ts`. Keep picking the leader and ordering the rest in the pure util. No empty files. + +# Unit test cases (BDD) + +```ts +describe('getArchetypeRanking()', () => { + it('returns the archetype with the highest match as the leader', ...); + it('returns the first in the given order when matches are equal', ...); + it('returns the rest sorted by match, highest first', ...); + it('treats a missing match as zero and lists it last', ...); + it('clamps a match outside 0-100', ...); + it('returns no leader for an empty list', ...); +}); + +describe('', () => { + describe('given a leader that is a match', () => { + it('renders its name as a heading and its bar in the match colour', ...); + it('renders the short description', ...); + }); + describe('given a leader that is a partial match', () => { + it('renders its bar in the partial colour', ...); + }); + describe('given a leader that is no match', () => { + it('renders the no match wording and an empty track', ...); + it('renders no description and no description control', ...); + it('includes the leader in the ranking', ...); + }); + describe('when the description control is pressed', () => { + it('renders the full description and marks the control active', ...); + it('returns to the summary when pressed again', ...); + }); + describe('when the ranking control is pressed', () => { + it('renders every other archetype as a ranked row, highest first', ...); + it('colours each bar by its own band, through the overridden orientation colour', ...); + it('does not pass the archetype own colour to any bar', ...); + it('returns to the summary when pressed again', ...); + }); + describe('when one view is open and the other control is pressed', () => { + it('switches straight to the other view', ...); + }); + describe('given a leader without a full description', () => { + it('renders no description control', ...); + }); + describe('given a full description identical to the short one', () => { + it('renders no description control', ...); + }); + describe('given only a full description', () => { + it('renders an empty summary and a description control', ...); + }); + describe('given one archetype', () => { + it('renders no ranking control', ...); + }); + describe('given neither control applies', () => { + it('renders no foot', ...); + }); + describe('given a description with markup', () => { + it('renders it as plain text', ...); + it('keeps paragraph breaks', ...); + }); + describe('given a comparison', () => { + it('passes each archetype value to its bar', ...); + it('does not change the leader or the order', ...); + }); + describe('given no archetypes', () => { + it('renders an empty card under its title', ...); + }); + describe('accessibility', () => { + it('gives both controls text names and says whether each view is open', ...); + it('keeps focus on the control that opened a view', ...); + }); +}); +``` + +# Storybook stories + +**Figma, in the frame's order** - use `play()` to open a view where the state needs it +- `Standard` +- `NoMatch` +- `Example` - the summary +- `FullDescription` +- `Ranking` + +**Edge** +- `PartialMatch` +- `NoDescription`, `OnlyFullDescription` +- `SingleArchetype`, `Empty` +- `Comparison`, `ComparisonRanking` +- `LongNames`, `MarkupInDescription` + +# Remember about standards + +- Use the standard colors palette, never add colors directly (check https://tailwindcss.com/docs/colors and our color palette in the `src/index.css` file and in [athena](https://github.com/gi-org-pl/athena/blob/main/src/index.css)) +- Create unit tests with Vitest for 100% of the code created if feasible (check our [testing convention](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/TESTING_CONVENTION.md)) +- Create a Storybook story for the component with all possible props variants of the component +- Comply with [the component structure](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/COMPONENT_STRUCTURE.md) +- Name the branch `feature/archetype-71`, following [Conventional Branch](https://conventional-branch.github.io/) - never keep a generated or default branch name +- Copy is Polish by default and fully translatable: every literal string and every ARIA text goes through Lingui macros, and the English entries are filled in (see Copy). Names and values come from props +- The PR follows the repository's pull request template, with screenshots of the Figma stories next to the Figma frame + +# Dependencies + +- `UniversalAxis` - done ([#60](https://github.com/gi-org-pl/mypolitics-app/issues/60)) +- `ModuleWrapper` - done ([#62](https://github.com/gi-org-pl/mypolitics-app/issues/62)) +- **Blocked by `ResultsHeader`** (#68) - it creates `getMatchBand` and the band constants +- **Blocked by `HorizontalBarChart`** (#69) - it creates `RankedRow`, which the ranking is made of + +# Resources + +- [Spec - Archetype](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/archetype.md) - every case, in full +- [Docs - Archetype](https://github.com/gi-org-pl/product/blob/main/mypolitics/docs/modules/quiz/results/modules/archetype.md) - why the module exists +- [Figma - Archetype frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-28187) - [standard](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-29630) | [no match](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-29532) | [example](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-28197) | [full description](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-28440) | [ranking](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-28544) | [the match bands](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-29502) +- [Front-end standards](https://github.com/Generacja-Innowacja/gi-tech-standards/tree/main/docs/frontend) +- [Storybook docs](https://storybook.js.org/docs/writing-stories) +- [Tailwind docs](https://tailwindcss.com/docs/) +- [Vitest docs](https://vitest.dev/guide/) + +# Definition of Done + +- [ ] Code follows folder structure (`docs/frontend/conventions/PROJECT_STRUCTURE.md`) +- [ ] Naming follows `docs/frontend/conventions/NAMING.md` +- [ ] Component layout follows `docs/frontend/conventions/COMPONENT_STRUCTURE.md` +- [ ] Bands come from `getMatchBand`; there is no second set of thresholds +- [ ] Every bar is coloured by its band through an overridden `orientation.color` from `MATCH_BAND_COLORS`; `UniversalAxis` is not changed +- [ ] No match shows the wording and an empty track, hides the description, and puts the leader in the ranking +- [ ] The three views are exclusive; each control toggles its own view and switches from the other +- [ ] A control is shown only when it has something to open +- [ ] The ranking is made of `RankedRow` and is never cut +- [ ] Descriptions render as plain text with paragraph breaks kept +- [ ] View state is local `useState`, starts in the summary and is not saved +- [ ] Invalid input degrades as in the table, without throwing +- [ ] Unit tests added, BDD style, coverage ≥95% on all new files +- [ ] Storybook stories added for all variants listed above +- [ ] Biome lint clean +- [ ] TypeScript clean (no `any`, no `@ts-ignore`) +- [ ] Every string from the Copy section goes through a Lingui macro, with Polish as the source; `yarn i18n:extract` run, English entries translated, `.po` files committed +- [ ] Branch named `feature/archetype-71` +- [ ] CI green: build, lint, test diff --git a/mypolitics/tasks/epics/modules/cycle-2/multi-axis-chart.md b/mypolitics/tasks/epics/modules/cycle-2/multi-axis-chart.md new file mode 100644 index 0000000..1021668 --- /dev/null +++ b/mypolitics/tasks/epics/modules/cycle-2/multi-axis-chart.md @@ -0,0 +1,283 @@ +Multi axis chart - collapsed and open + +# Story + +As a user reading my quiz result, I want a whole model of axes shown in one card as a short list of groups, each telling me which side I came out on, and I want to open a group to see every axis inside it, so that dozens of axes stay readable instead of turning into a wall of charts. + +# Component properties + +**Component:** `MultiAxisChart` +**Location:** `src/components/results/modules/MultiAxisChart/` +**Shared:** no - a result module, in the `results` domain + +A `ModuleWrapper` around a list of groups. Each group is drawn as one double-sided `UniversalAxis` and opens to show every axis it holds. It remembers which group is open; **no scoring**. This task also creates **`AxisRow`** as its own component, because the `NolanChart` module opens into the same rows. + +```ts +export interface AxisPair { + id: string; + start: ResultEntry; // from src/types/results.ts + end: ResultEntry; +} + +export interface AxisGroup { + name?: string; + axes: AxisPair[]; // the first one is the headline axis +} + +export interface AxisComparison { + party: AxisOrientation; // the other party + values: Record; // their value on each axis, by axis id +} + +export interface MultiAxisChartProps { + title?: string; + groups: AxisGroup[]; + marker?: number | false; // the same on every bar; default 50 + comparison?: AxisComparison; + onStatsClick?: () => void; + onInfoClick?: () => void; +} + +// src/components/results/modules/AxisRow/ +export interface AxisRowProps { + name?: string; // the axis or group name, drawn quiet + leadName?: string; // drawn strong after the name; absent on a tie + start: ResultEntry; + end: ResultEntry; + marker?: number | false; + showLabels?: boolean; + comparison?: AxisEntry; +} +``` + +Which group is open is local state (`useState`). It is not saved anywhere. + +# Behaviour + +The [spec](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/multi-axis-chart.md) is the source of truth for every case below; the [Figma frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-2487) is the source of truth for sizes, spacing, type, icons and colours. + +### Headline axis + +The **first axis of a group is its headline axis**: the pair that summarises the group. It is the bar drawn while the group is closed, and its lead - from `getAxisLead` - gives the group its name. A narrower axis with a stronger result does **not** rename the group. + +### Axis row + +| Case | Behaviour | +|---|---| +| Heading, with a lead | The name, quiet, then the leading orientation's name, strong | +| Heading, on a tie | The name alone | +| Bar | Double-sided `UniversalAxis` with the marker, and with labels when `showLabels` is on | +| Comparison value for this axis | Passed to the bar | + +### Closed group + +| Case | Behaviour | +|---|---| +| A group | One axis row for its headline axis, with labels | +| Group with more than one axis | Under the bar, a preview of the orientations inside - icons of the start poles on one side, of the end poles on the other - and a control that opens the group | +| Group with one axis | No preview and no control | +| The list of groups | Every group, in the order given, never sorted | + +### Open group + +| Case | Behaviour | +|---|---| +| A group opened | **The card shows that group alone** | +| Its content | The group's heading and headline bar, then every other axis as a labelled bar with no heading of its own, in the order given | +| The marker | One line running through every bar of the group | +| Closing | A control at the foot of the card returns to the list of groups | + +One group is open at a time. The continuous marker must sit exactly where each bar would draw its own, without measuring anything: no `getBoundingClientRect`, no `ResizeObserver`. + +### Two slips in the Figma frame + +- One "standard" row is headed "Orientation B" while A has 47 against 31. The heading follows the lead rule: it would name A. +- One open bar shows "0%". `UniversalAxis` hides a side too small to hold its number, and that rule wins. + +### Invalid and edge input + +| Input | Behaviour | +|---|---| +| Group with no axes | Not drawn | +| Group without a name | The heading shows the lead alone; on a tie there is no heading | +| Axis with one value absent | That side unfilled; the other side leads if it is above zero | +| Axis with both values absent | An empty double-sided track, and a tie | +| Orientation without an icon | Left out of the preview | +| More preview icons than fit | The row is cut at what fits | +| Names longer than the room | Truncated; complete for assistive technology | +| Comparison value for an axis not in the module | Ignored | +| No groups | An empty card under its title | + +### Accessibility + +- The groups are a list, and each group's heading is a heading. +- The preview icons are decorative. +- The open and close controls are buttons that name the group, say whether it is open, and work from the keyboard. +- When a group opens or closes, focus moves to the control that replaces the one pressed. It is never lost. +- The lead is never carried by colour alone: the heading says it in words. + +# Copy + +Polish is the source language, and every string is translatable. Each one below goes through a Lingui macro - `` for JSX text, `t` or `msg` for attributes and ARIA text - with no hardcoded literals. After adding them run `yarn i18n:extract`, fill in the English entries in `src/locales/en/messages.po`, and commit both `.po` files. The wording is a proposal: adjust it if needed, but keep the meaning. + +| Where | Polish (source) | English | +|---|---|---| +| Control, open a group | Pokaż grupę: {name} | Show group: {name} | +| Control, return to the groups | Wróć do grup | Back to groups | + +# Athena components to use + +- `ModuleWrapper` and `UniversalAxis` from `src/components/shared/` - do not rebuild either. +- `getAxisLead` and `ResultEntry` from the `DoubleAxisChart` task - do not write a second lead rule. +- **`Button`** with `isIconButton` for the open and close controls, if it can match the chevron controls in Figma. +- **`Avatar`** is not needed here: the preview icons are small plain images. +- No accordion or chart library. + +# Out of scope + +- Anything the bar draws - it is `UniversalAxis`. +- The lead rule - it is `getAxisLead`. +- Defining the groups and what belongs in each. +- Saving which group the taker opened. + +# Files to create + +``` +src/components/results/modules/MultiAxisChart/ +├── MultiAxisChart.tsx +├── MultiAxisChart.test.tsx +├── MultiAxisChart.types.ts +└── MultiAxisChart.stories.tsx + +src/components/results/modules/AxisRow/ +├── AxisRow.tsx +├── AxisRow.test.tsx +├── AxisRow.types.ts +└── AxisRow.stories.tsx +``` + +Split the closed group or the preview into a subcomponent only if the main view gets hard to read. No empty files. + +# Unit test cases (BDD) + +```ts +describe('', () => { + it('renders the name quiet and the lead name strong', ...); + it('renders the name alone when there is no lead name', ...); + it('renders no heading when there is neither', ...); + it('renders a double-sided bar with the marker', ...); + it('renders labels only when showLabels is on', ...); + it('passes a comparison to the bar', ...); +}); + +describe('', () => { + describe('given groups', () => { + it('renders one row per group, in the given order', ...); + it('draws the first axis of each group as its bar', ...); + it('heads each group with its name and the headline axis lead', ...); + it('does not rename a group after a stronger axis inside it', ...); + }); + describe('given a group whose headline axis is a tie', () => { + it('heads it with the group name alone', ...); + }); + describe('given a group with more than one axis', () => { + it('renders the preview icons, start poles on one side and end poles on the other', ...); + it('renders a control that opens the group', ...); + it('leaves an orientation without an icon out of the preview', ...); + }); + describe('given a group with one axis', () => { + it('renders no preview and no control', ...); + }); + describe('when a group is opened', () => { + it('renders that group alone', ...); + it('renders every other axis as a labelled bar with no heading', ...); + it('renders one marker line through the group', ...); + it('moves focus to the control that returns to the list', ...); + }); + describe('when the return control is pressed', () => { + it('renders the list of groups again', ...); + }); + describe('given a comparison', () => { + it('passes each axis value to its bar, closed and open', ...); + it('ignores values for axes not in the module', ...); + }); + describe('given marker is false', () => { + it('renders no marker on any bar and no line through an open group', ...); + }); + describe('given a group with no axes', () => { + it('does not render it', ...); + }); + describe('given a group without a name', () => { + it('heads it with the lead alone', ...); + }); + describe('given no groups', () => { + it('renders an empty card under its title', ...); + }); + describe('accessibility', () => { + it('renders the groups as a list with headings', ...); + it('names the group on each control and says whether it is open', ...); + }); +}); +``` + +# Storybook stories + +**Figma, in the frame's order** - use `play()` to open a group where the state needs it +- `Standard` +- `GroupOpen` +- `Example` +- `ExampleGroupOpen` + +**Edge** +- `SingleAxisGroup` - nothing to open +- `Tie` - a group with no winner +- `Comparison`, `ComparisonGroupOpen` +- `ManyPreviewIcons`, `LongNames` +- `MissingValues`, `NoMarker`, `NoGroups` + +# Remember about standards + +- Use the standard colors palette, never add colors directly (check https://tailwindcss.com/docs/colors and our color palette in the `src/index.css` file and in [athena](https://github.com/gi-org-pl/athena/blob/main/src/index.css)) +- Create unit tests with Vitest for 100% of the code created if feasible (check our [testing convention](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/TESTING_CONVENTION.md)) +- Create a Storybook story for the component with all possible props variants of the component +- Comply with [the component structure](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/COMPONENT_STRUCTURE.md) +- Name the branch `feature/multi-axis-chart-70`, following [Conventional Branch](https://conventional-branch.github.io/) - never keep a generated or default branch name +- Copy is Polish by default and fully translatable: every literal string and every ARIA text goes through Lingui macros, and the English entries are filled in (see Copy). Names and values come from props +- The PR follows the repository's pull request template, with screenshots of the Figma stories next to the Figma frame + +# Dependencies + +- `UniversalAxis` - done ([#60](https://github.com/gi-org-pl/mypolitics-app/issues/60)) +- `ModuleWrapper` - done ([#62](https://github.com/gi-org-pl/mypolitics-app/issues/62)) +- **Blocked by `DoubleAxisChart`** (#66) - it creates `getAxisLead` and `ResultEntry`, which this module uses for every group + +# Resources + +- [Spec - Multi axis chart](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/multi-axis-chart.md) - every case, in full +- [Docs - Multi axis chart](https://github.com/gi-org-pl/product/blob/main/mypolitics/docs/modules/quiz/results/modules/multi-axis-chart.md) - why the module exists +- [Figma - Multi axis chart frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-2487) - [standard](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5507-11167) | [group open](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5507-11482) | [example](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5507-11683) | [example, group open](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5507-11900) +- [Front-end standards](https://github.com/Generacja-Innowacja/gi-tech-standards/tree/main/docs/frontend) +- [Storybook docs](https://storybook.js.org/docs/writing-stories) +- [Tailwind docs](https://tailwindcss.com/docs/) +- [Vitest docs](https://vitest.dev/guide/) + +# Definition of Done + +- [ ] Code follows folder structure (`docs/frontend/conventions/PROJECT_STRUCTURE.md`) +- [ ] Naming follows `docs/frontend/conventions/NAMING.md` +- [ ] Component layout follows `docs/frontend/conventions/COMPONENT_STRUCTURE.md` +- [ ] `AxisRow` is its own component, usable without `MultiAxisChart` +- [ ] A group is named by its first axis's lead, through `getAxisLead`; there is no second lead rule +- [ ] A group with one axis has no preview and no control +- [ ] A group opens to show that group alone, with one marker line through its bars, and returns to the list +- [ ] Nothing is measured to place the continuous marker +- [ ] Open state is local `useState`, starts closed and is not saved +- [ ] Controls are keyboard-operable, name their group and say whether it is open; focus is never lost +- [ ] Invalid input degrades as in the table, without throwing +- [ ] Unit tests added, BDD style, coverage ≥95% on all new files +- [ ] Storybook stories added for all variants listed above +- [ ] Biome lint clean +- [ ] TypeScript clean (no `any`, no `@ts-ignore`) +- [ ] Every string from the Copy section goes through a Lingui macro, with Polish as the source; `yarn i18n:extract` run, English entries translated, `.po` files committed +- [ ] Branch named `feature/multi-axis-chart-70` +- [ ] CI green: build, lint, test diff --git a/mypolitics/tasks/epics/modules/cycle-3/nolan-chart.md b/mypolitics/tasks/epics/modules/cycle-3/nolan-chart.md new file mode 100644 index 0000000..95b32cf --- /dev/null +++ b/mypolitics/tasks/epics/modules/cycle-3/nolan-chart.md @@ -0,0 +1,362 @@ +Nolan chart - the compass, its levels and the maths behind them + +# Story + +As a user reading my quiz result, I want to see where my two scores put me on a map of four quadrants, with the title telling me which quadrant I am in and how far out, and I want to open the card to check the map against the two axes it was built from. + +# Component properties + +**Component:** `NolanChart` +**Location:** `src/components/results/modules/NolanChart/` +**Shared:** no - a result module, in the `results` domain + +A `ModuleWrapper` around a square map with a dot at the taker's position. It turns two pairs of values into a position and a level, and remembers whether it is open; **no scoring**. + +```ts +export interface NolanLevelNames { + moderate?: string; // a complete phrase, e.g. "Umiarkowana zielona" + extreme?: string; // a complete phrase, e.g. "Skrajna zielona" +} + +export interface NolanPole { + entry: ResultEntry; // from src/types/results.ts; the orientation's imageUrl is the cap icon + names?: NolanLevelNames; // what a lean toward this pole is called +} + +export interface NolanAxis { + name: string; + start: NolanPole; // at -1: left on the horizontal axis, bottom on the vertical + end: NolanPole; // at 1: right on the horizontal axis, top on the vertical +} + +export interface NolanQuadrant { + color?: string; // author-supplied + names?: NolanLevelNames; +} + +export interface NolanComparison { + party: AxisOrientation; + horizontal: { start?: number; end?: number }; + vertical: { start?: number; end?: number }; +} + +export interface NolanChartProps { + horizontal: NolanAxis; + vertical: NolanAxis; + quadrants: { + topLeft: NolanQuadrant; + topRight: NolanQuadrant; + bottomLeft: NolanQuadrant; + bottomRight: NolanQuadrant; + }; + centreName?: string; + comparison?: NolanComparison; + onStatsClick?: () => void; + onInfoClick?: () => void; +} +``` + +**Never build a name out of a level and a noun.** Every name is a complete phrase from the author, because in Polish the two halves have to agree. Figma itself shows both "Umiarkowana zielona" and "Umiarkowany zielony". + +Whether the card is open is local state (`useState`). It is not saved anywhere. + +# Behaviour + +The [spec](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/nolan-chart.md) is the source of truth for every case below; the [Figma frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5508-25681) is the source of truth for sizes, spacing, type, icons and colours. + +### Position + +Each axis becomes one coordinate between -1 and 1: + +``` +coordinate = (end value - start value) / 100 +``` + +Values are clamped to 0-100 first. Coordinates are computed from the exact values and shown rounded to two decimals. + +### Level + +For the whole position, with `r = √(x² + y²)`: + +| Level | Condition | +|---|---| +| Centre | `r` below `√2 / 3` (about 0.47) | +| Moderate | `r` from `√2 / 3`, below 1 | +| Extreme | `r` of 1 and above | + +Along a single axis, on the coordinate alone: + +| Level | Condition | +|---|---| +| Centre | Nearer to zero than 1/3 | +| Moderate | From 1/3, short of 1 | +| Extreme | Exactly at the pole, 1 or -1 | + +The quadrant is given by the signs of the two coordinates. A coordinate of exactly zero counts toward the end pole. The thresholds are constants, the same for every quiz. + +### Title + +Passed to `ModuleWrapper` as a component title. + +| Case | Behaviour | +|---|---| +| Centre | The centre name, in the plain title style, with no colour | +| Moderate | The quadrant's moderate name, as coloured text on a pale tint of the quadrant's colour | +| Extreme | The quadrant's extreme name, on the quadrant's colour at full strength | +| No position | The "no result" wording (see Copy), in the plain title style | + +### Map + +| Case | Behaviour | +|---|---| +| Always | A square of four quadrants, each in a pale tint of its colour | +| Moderate or extreme | The taker's quadrant is filled with its colour at full strength | +| Centre | No quadrant is filled | +| The taker | A dot with a halo at the position | +| Dot at an edge or a corner | Its centre is placed exactly; whatever falls outside the map is cut off | +| No position | No dot and no filled quadrant | + +Each axis is named beside the map with its coordinate: the horizontal axis under the map, the vertical axis along its side. The map is always square. + +### Opened + +| Case | Behaviour | +|---|---| +| Closed | A control at the foot of the card opens it | +| Opened | Two `AxisRow`s appear under the map, horizontal axis first, and the control at the foot closes the card | +| A row's heading | The axis's name, then the pole name for the taker's level along that axis | +| A row's heading at the centre level | The axis's name alone | +| A row's bar | Double-sided, with the marker and no labels. The caps carry the poles' icons | +| A row's colours | The side the taker leans to takes the quadrant's colour; the other side is neutral | + +### Comparison + +| Case | Behaviour | +|---|---| +| Comparison present | The other party's image is drawn at their position, on a hatched disc | +| Their quadrant | Never filled | +| Their position at an edge or a corner | Moved inward so the image stays fully on the map | +| Both at the same position | The other party's image is drawn on top; the taker's halo stays visible around it | +| The other party has no position | No second marker | +| Opened | Both rows carry the comparison band | + +The comparison value passed to each row's bar is the other party's value for the **start** pole, since the bar measures the band against its start entry. + +### No position is not the centre + +An axis with either value absent means the quiz could not place the taker: no dot, no filled quadrant, and "Brak wyniku". The centre is only for a taker who was placed there. + +### Invalid and edge input + +| Input | Behaviour | +|---|---| +| An axis with either value absent or not a number | No position | +| A pair that does not add up to 100 | The coordinate is still the difference over 100 | +| Quadrant name missing for the taker's level | The quadrant's other name, and failing that the centre name | +| Centre name missing | No title; the wrapper draws its header without one | +| Pole name missing | The row's heading shows the axis's name alone | +| Quadrant without a colour | The neutral fallback: tint, fill and title | +| Axis name or title longer than the room | Truncated; complete for assistive technology | +| Other party without an image | A neutral placeholder on the hatched disc | + +### Accessibility + +- The map is announced as a single image. Its description carries the quadrant name and level, both axis names with their coordinates, and the other party's quadrant when there is a comparison. +- The level is never carried by colour alone: the title says it in words. +- The open and close control is a button that says what it does and whether the card is open, and works from the keyboard. +- Pass the title text to `ModuleWrapper` as `ariaLabel`. + +# Copy + +Polish is the source language, and every string is translatable. Each one below goes through a Lingui macro - `` for JSX text, `t` or `msg` for attributes and ARIA text - with no hardcoded literals. After adding them run `yarn i18n:extract`, fill in the English entries in `src/locales/en/messages.po`, and commit both `.po` files. The wording is a proposal: adjust it if needed, but keep the meaning. + +| Where | Polish (source) | English | +|---|---|---| +| Title, no position | Brak wyniku | No result | +| Control, open | Pokaż osie | Show axes | +| Control, close | Ukryj osie | Hide axes | +| Map description | {title}. {xAxis}: {x}, {yAxis}: {y} | {title}. {xAxis}: {x}, {yAxis}: {y} | +| Map description, the other party | {name}: {quadrant} | {name}: {quadrant} | + +# Athena components to use + +- `ModuleWrapper` and `UniversalAxis` from `src/components/shared/`, and `AxisRow` from the `MultiAxisChart` task - do not rebuild any of them. +- **`Avatar`** for the other party's image on the map. +- **`Button`** with `isIconButton` for the open and close control, if it can match the chevron control in Figma. +- The map has no Athena equivalent. Build it with plain elements or SVG and Tailwind; **no charting library**. +- The hatched disc uses the same hatching as `UniversalAxis`. Reuse it; do not draw a second pattern. +- Orientation colours are author-supplied data, not design tokens. Apply them at runtime through a CSS custom property, exactly as `UniversalAxis` does (approved in [mypolitics-app#61](https://github.com/gi-org-pl/mypolitics-app/pull/61#issuecomment-6007060837)), and accept only values that pass `SAFE_COLOR_PATTERN`. That check lives in `UniversalAxis.constants.ts` today: promote it to a shared place instead of copying it. Every other colour comes from the palette. The quadrant colours are the author-supplied colours here. + +# Out of scope + +- Anything the bar draws - it is `UniversalAxis`. +- Scoring, and choosing which two axes are crossed. +- Naming the quadrants and poles - the names arrive as props. +- More than one other party on the map. +- Saving whether the card is open. + +# Files to create + +``` +src/components/results/modules/NolanChart/ +├── NolanChart.tsx +├── NolanChart.test.tsx +├── NolanChart.types.ts +├── NolanChart.constants.ts # the level thresholds +├── NolanChart.stories.tsx +└── utils/ + ├── getNolanPosition.ts # pure: two pairs in; coordinates, r, level and quadrant out, or null + ├── getNolanPosition.test.ts + ├── getAxisLevel.ts # pure: one coordinate in, its level and the pole it leans to out + └── getAxisLevel.test.ts +``` + +Keep all the maths in the pure utils so every threshold is tested without rendering. Split the map into a subcomponent if the view gets hard to read. No empty files. + +# Unit test cases (BDD) + +```ts +describe('getNolanPosition()', () => { + describe('coordinates', () => { + it('returns (end - start) / 100 for each axis', ...); + it('clamps values to 0-100 first', ...); + it('uses the difference even when a pair does not add up to 100', ...); + it('returns null when either value of an axis is absent or not a number', ...); + }); + describe('level', () => { + it('returns centre below √2/3', ...); + it('returns moderate at √2/3', ...); + it('returns moderate just below 1', ...); + it('returns extreme at 1', ...); + it('returns extreme at a corner', ...); + }); + describe('quadrant', () => { + it('returns each of the four quadrants from the signs', ...); + it('counts a coordinate of zero toward the end pole', ...); + }); +}); + +describe('getAxisLevel()', () => { + it('returns centre nearer to zero than 1/3', ...); + it('returns moderate at 1/3', ...); + it('returns moderate just short of 1', ...); + it('returns extreme at 1 and at -1', ...); + it('returns the pole the coordinate leans to', ...); +}); + +describe('', () => { + describe('given a centre position', () => { + it('renders the centre name in the plain title style', ...); + it('fills no quadrant', ...); + }); + describe('given a moderate position', () => { + it('renders the quadrant moderate name on a pale tint', ...); + it('fills the taker quadrant', ...); + }); + describe('given an extreme position', () => { + it('renders the quadrant extreme name on the full colour', ...); + }); + describe('given no position', () => { + it('renders the no result wording, no dot and no filled quadrant', ...); + }); + describe('map', () => { + it('renders the dot at the position', ...); + it('names both axes with their coordinates rounded to two decimals', ...); + it('is announced as a single described image', ...); + }); + describe('when the card is opened', () => { + it('renders two axis rows, horizontal first', ...); + it('heads each row with the axis name and the pole name for its level', ...); + it('heads a row at the centre level with the axis name alone', ...); + it('colours the side the taker leans to with the quadrant colour', ...); + it('renders the rows without labels', ...); + }); + describe('when the card is closed again', () => { + it('renders no axis rows', ...); + }); + describe('given a comparison', () => { + it('renders the other party at their position, on a hatched disc', ...); + it('never fills the other party quadrant', ...); + it('moves the other party inward at a corner', ...); + it('renders no second marker when the other party has no position', ...); + it('passes the comparison to both rows when opened', ...); + it('includes the other party quadrant in the map description', ...); + }); + describe('given missing names', () => { + it('falls back to the quadrant other name, then to the centre name', ...); + it('heads a row with the axis name alone when the pole name is missing', ...); + }); + describe('given a quadrant without a colour', () => { + it('uses the neutral fallback', ...); + }); + describe('accessibility', () => { + it('says on the control whether the card is open', ...); + }); +}); +``` + +# Storybook stories + +**Figma, in the frame's order** - use `play()` to open the card where the state needs it +- `Standard` - placeholders +- `Centre` +- `Moderate` +- `ModerateOpen` +- `ExtremeOpen` +- `Comparison` + +**Edge** +- `NoPosition` +- `OnAnAxis` - one coordinate at zero +- `Corner` - the dot cut off by the map's edge +- `ComparisonAtCorner`, `ComparisonSamePosition`, `ComparisonWithoutPosition` +- `MissingNames`, `NoQuadrantColor`, `LongNames` + +# Remember about standards + +- Use the standard colors palette, never add colors directly (check https://tailwindcss.com/docs/colors and our color palette in the `src/index.css` file and in [athena](https://github.com/gi-org-pl/athena/blob/main/src/index.css)) +- Create unit tests with Vitest for 100% of the code created if feasible (check our [testing convention](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/TESTING_CONVENTION.md)) +- Create a Storybook story for the component with all possible props variants of the component +- Comply with [the component structure](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/COMPONENT_STRUCTURE.md) +- Name the branch `feature/nolan-chart-72`, following [Conventional Branch](https://conventional-branch.github.io/) - never keep a generated or default branch name +- Copy is Polish by default and fully translatable: every literal string and every ARIA text goes through Lingui macros, and the English entries are filled in (see Copy). Names and values come from props +- The PR follows the repository's pull request template, with screenshots of the Figma stories next to the Figma frame + +# Dependencies + +- `UniversalAxis` - done ([#60](https://github.com/gi-org-pl/mypolitics-app/issues/60)) +- `ModuleWrapper` - done ([#62](https://github.com/gi-org-pl/mypolitics-app/issues/62)) +- **Blocked by `MultiAxisChart`** (#70) - it creates `AxisRow`, which this module opens into + +# Resources + +- [Spec - Nolan chart](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/nolan-chart.md) - every case, in full +- [Docs - Nolan chart](https://github.com/gi-org-pl/product/blob/main/mypolitics/docs/modules/quiz/results/modules/nolan-chart.md) - why the module exists +- [Figma - Nolan chart frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5508-25681) - [standard](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-28142) | [centre](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-26955) | [moderate](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5508-25999) | [moderate, open](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5509-26520) | [extreme, open](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5509-26202) | [comparison](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5514-40357) | [the maths](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5513-27284) +- [Front-end standards](https://github.com/Generacja-Innowacja/gi-tech-standards/tree/main/docs/frontend) +- [Storybook docs](https://storybook.js.org/docs/writing-stories) +- [Tailwind docs](https://tailwindcss.com/docs/) +- [Vitest docs](https://vitest.dev/guide/) + +# Definition of Done + +- [ ] Code follows folder structure (`docs/frontend/conventions/PROJECT_STRUCTURE.md`) +- [ ] Naming follows `docs/frontend/conventions/NAMING.md` +- [ ] Component layout follows `docs/frontend/conventions/COMPONENT_STRUCTURE.md` +- [ ] All the maths lives in `getNolanPosition` and `getAxisLevel`, with every threshold tested at its boundary +- [ ] The thresholds are constants in `NolanChart.constants.ts` +- [ ] Names are never composed from a level and a noun +- [ ] No position renders "Brak wyniku" and no dot - it is never drawn as the centre +- [ ] The taker's dot is placed exactly and cut by the map's edge; the other party's image is kept fully on the map +- [ ] The card opens into two `AxisRow`s; there is no second row component +- [ ] The map is one described image; no charting library +- [ ] The author-supplied quadrant colour is applied only through a CSS custom property and validated +- [ ] Open state is local `useState`, starts closed and is not saved +- [ ] Invalid input degrades as in the table, without throwing +- [ ] Unit tests added, BDD style, coverage ≥95% on all new files +- [ ] Storybook stories added for all variants listed above +- [ ] Biome lint clean +- [ ] TypeScript clean (no `any`, no `@ts-ignore`) +- [ ] Every string from the Copy section goes through a Lingui macro, with Polish as the source; `yarn i18n:extract` run, English entries translated, `.po` files committed +- [ ] Branch named `feature/nolan-chart-72` +- [ ] CI green: build, lint, test diff --git a/mypolitics/tasks/epics/results/cycle-1/module-wrapper.md b/mypolitics/tasks/epics/results/cycle-1/module-wrapper.md new file mode 100644 index 0000000..548bac4 --- /dev/null +++ b/mypolitics/tasks/epics/results/cycle-1/module-wrapper.md @@ -0,0 +1,261 @@ +Module wrapper - the four configurations drawn in Figma + +# Story + +As a user reading my quiz result, I want every module to sit in the same card - a title, a button for statistics, a button for the explanation, and the result under them - so that a screen of different charts reads as one product and I always know where to look for what a number means. + +# Component properties + +**Component:** `ModuleWrapper` +**Location:** `src/components/shared/ModuleWrapper/` +**Shared:** yes - used by the result modules and by the quiz editor + +Purely presentational: **no state, no data access, no knowledge of what the body is**. It does not open the statistics or the explanation itself; it only reports that a button was pressed. + +```ts +export interface ModuleWrapperProps { + title?: ReactNode; // a string is a text title; any other node is a component title + ariaLabel?: string; // name of the card for assistive technology; needed with a component title + onStatsClick?: () => void; // the statistics button is drawn only when this is passed + onInfoClick?: () => void; // the info button is drawn only when this is passed + children?: ReactNode; // the body +} +``` + +There is deliberately no `showStats` / `showInfo` flag: an action exists exactly when something is listening for it, so a button that does nothing cannot be drawn. + +# Behaviour + +The [spec](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/module-wrapper.md) is the source of truth for every case below; the [Figma frame](https://www.figma.com/design/DIInW4qrIxsgXmKbSHukNm/mypolitics-app?node-id=5500-3483) is the source of truth for sizes, spacing, type, icons and colours. + +Mind the Figma labels: there, "action" means the title is a component, not that the buttons are present. The variant labelled "without actions" has both buttons. + +### Title + +| Case | Behaviour | +|---|---| +| Text title | Drawn in the title pill, centred, on one line | +| Text title longer than the slot | Truncated visually, complete for assistive technology | +| Component title | Replaces the pill. The wrapper adds no border, background or text styling of its own | +| Component title larger than the slot | Clipped to the slot. It never grows the header or pushes a button out | +| Text title pressed | Nothing happens. A text title is not interactive | + +### Actions + +| Case | Behaviour | +|---|---| +| Both handlers passed | Statistics first, info last, at the end of the header | +| `onStatsClick` only | One button, at the end of the header | +| `onInfoClick` only | One button, at the end of the header | +| Neither | No buttons | +| Button pressed | Its handler is called once per press. Nothing else on the card changes | + +A missing button leaves no gap: the title slot takes the room. There is no disabled, loading or badge state for either button. + +### Layout + +| Case | Behaviour | +|---|---| +| Header | Title slot first, actions after it, on one row of fixed height | +| Title slot width | Whatever the actions leave - widest with no actions, narrowest with two | +| Divider | Across the full card width, between header and body | +| Card width | The width the card is given | +| Card height | The header plus whatever the body needs. No minimum, no maximum | + +There is **no compact variant**: the card keeps its padding and header height at every width. + +### Configurations + +The title kind and each button are independent, which gives eight configurations. Figma draws four; the other four follow the same layout rule and need no design of their own. + +| Configuration | Figma label | +|---|---| +| Text title, no actions | "no actions nor stats/info" | +| Text title, statistics and info | "without actions" | +| Component title, statistics and info | "with action" | +| Component title, statistics only | "with action, w/o info" | +| Text title with one action, component title with info only, component title with no actions | not drawn | + +### Invalid and edge input + +Nothing here throws - titles are author-supplied and can be wrong, and a card with a bad title must still show its result. + +| Input | Behaviour | +|---|---| +| Title missing, empty or only whitespace | Treated as no title | +| No title and no actions | The header and the divider are not drawn. The card is its body alone | +| No title, with actions | The header is drawn with the actions in their usual place and an empty title slot | +| Title text with line breaks | Collapsed to one line | +| Body missing or empty | Header and divider are drawn, nothing under them | +| No title, no actions and no body | An empty card | +| Body wider than the card | The body gets the card's inner width. The wrapper never scrolls sideways | + +### Rules + +- The two buttons are the only controls the wrapper adds - no menu, no close, no collapse, no link on the card itself. +- The wrapper never looks inside the body: it does not measure it, scroll it or clip it to a height. +- The wrapper never hides itself. Leaving a module out is the caller's decision. +- A caller cannot restyle the card, move the actions or remove the divider. The title slot is the only part it controls, so do not expose a `className` for the card. +- Nothing may depend on hover or on viewport size: where a result is drawn without interaction, the caller simply passes no handlers and gets the titled card. + +### Accessibility + +- The card is a labelled region, named by its text title, or by `ariaLabel` when the title is a component. +- A text title is a heading, so a screen reader user can move from module to module. +- Both buttons are icon-only, so each has a text name that includes the module's name, for example "Statystyki: {name}" and "Informacje: {name}". A screen with a dozen modules must not announce a dozen identical buttons. With no name available, the button name stands alone. +- Focus order follows reading order: the title component if it is interactive, statistics, info, then the body. +- Both buttons work from the keyboard and show a visible focus state. +- The pressable area of each button is at least 44x44 CSS px although the button is drawn smaller, and the two areas do not overlap. + +# Athena components to use + +- **`Button`** with `isIconButton` for the two actions, if it can match the round icon button in Figma. If it cannot, use a native `