diff --git a/frontend/resources/images/icons/collapse.svg b/frontend/resources/images/icons/collapse.svg
new file mode 100644
index 0000000000..34d2071c12
--- /dev/null
+++ b/frontend/resources/images/icons/collapse.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/git-compare-arrows.svg b/frontend/resources/images/icons/git-compare-arrows.svg
new file mode 100644
index 0000000000..9ed02d600a
--- /dev/null
+++ b/frontend/resources/images/icons/git-compare-arrows.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/resources/images/icons/side-by-side.svg b/frontend/resources/images/icons/side-by-side.svg
new file mode 100644
index 0000000000..0f425d8d59
--- /dev/null
+++ b/frontend/resources/images/icons/side-by-side.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs
index f65ed4f5ce..162d2877a5 100644
--- a/frontend/src/app/main/ui/ds.cljs
+++ b/frontend/src/app/main/ui/ds.cljs
@@ -37,14 +37,18 @@
[app.main.ui.ds.product.branch-pill :refer [branch-pill*]]
[app.main.ui.ds.product.branch-row :refer [branch-row*]]
[app.main.ui.ds.product.branch-status :refer [branch-status*]]
+ [app.main.ui.ds.product.change-row :refer [change-row*]]
[app.main.ui.ds.product.cta :refer [cta*]]
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
[app.main.ui.ds.product.empty-state :refer [empty-state*]]
+ [app.main.ui.ds.product.filter-chip :refer [filter-chip*]]
[app.main.ui.ds.product.input-with-meta :refer [input-with-meta*]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.ds.product.milestone :refer [milestone*]]
[app.main.ui.ds.product.milestone-group :refer [milestone-group*]]
[app.main.ui.ds.product.panel-title :refer [panel-title*]]
+ [app.main.ui.ds.product.preview-card :refer [preview-card*]]
+ [app.main.ui.ds.product.tag :refer [tag*]]
[app.main.ui.ds.storybook :as sb]
[app.main.ui.ds.tooltip.tooltip :refer [tooltip*]]
[app.main.ui.ds.utilities.date :refer [date*]]
@@ -89,6 +93,10 @@
:BranchPill branch-pill*
:BranchRow branch-row*
:BranchStatus branch-status*
+ :ChangeRow change-row*
+ :FilterChip filter-chip*
+ :PreviewCard preview-card*
+ :Tag tag*
:Milestone milestone*
:MilestoneGroup milestone-group*
:Date date*
diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
index c3f17bce70..7dad14efd3 100644
--- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
+++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
@@ -100,6 +100,7 @@
(def ^:icon-id close "close")
(def ^:icon-id close-small "close-small")
(def ^:icon-id code "code")
+(def ^:icon-id collapse "collapse")
(def ^:icon-id column "column")
(def ^:icon-id column-reverse "column-reverse")
(def ^:icon-id comments "comments")
@@ -158,6 +159,7 @@
(def ^:icon-id git-branch-plus "git-branch-plus")
(def ^:icon-id git-commit "git-commit")
(def ^:icon-id git-commit-vertical "git-commit-vertical")
+(def ^:icon-id git-compare-arrows "git-compare-arrows")
(def ^:icon-id git-merge "git-merge")
(def ^:icon-id git-pull-request-arrow "git-pull-request-arrow")
(def ^:icon-id graphics "graphics")
@@ -246,6 +248,7 @@
(def ^:icon-id separate-nodes "separate-nodes")
(def ^:icon-id settings "settings")
(def ^:icon-id shown "shown")
+(def ^:icon-id side-by-side "side-by-side")
(def ^:icon-id size-horizontal "size-horizontal")
(def ^:icon-id size-vertical "size-vertical")
(def ^:icon-id snap-nodes "snap-nodes")
diff --git a/frontend/src/app/main/ui/ds/product/change_row.cljs b/frontend/src/app/main/ui/ds/product/change_row.cljs
new file mode 100644
index 0000000000..c3e0c4de65
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/change_row.cljs
@@ -0,0 +1,73 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
+
+(ns app.main.ui.ds.product.change-row
+ (:require-macros
+ [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
+ [app.main.ui.ds.foundations.typography :as t]
+ [app.main.ui.ds.foundations.typography.text :refer [text*]]
+ [app.main.ui.ds.product.tag :as tag]
+ [app.util.keyboard :as kbd]
+ [rumext.v2 :as mf]))
+
+(def ^:private schema:change-row
+ [:map
+ [:class {:optional true} [:maybe :string]]
+ [:title :string]
+ [:subtitle {:optional true} [:maybe :string]]
+ [:icon {:optional true} [:maybe [:and :string [:fn #(contains? i/icon-list %)]]]]
+ [:tone {:optional true} [:maybe (into [:enum] tag/tones)]]
+ [:is-selected {:optional true} [:maybe :boolean]]
+ [:is-muted {:optional true} [:maybe :boolean]]
+ [:on-click {:optional true} [:maybe fn?]]])
+
+(mf/defc change-row*
+ "One entry of a change list: a short bar in the tone of its status
+ beside a card holding an optional icon, a title over a one-line summary, and a slot at
+ each end (`start` for a checkbox or a chevron, `end` for a tag). A
+ muted row reads as left out."
+ {::mf/schema schema:change-row}
+ [{:keys [class title subtitle icon tone is-selected is-muted start end on-click] :rest props}]
+ (let [tone (d/nilv tone "neutral")
+ is-selected (d/nilv is-selected false)
+ is-muted (d/nilv is-muted false)
+ on-key-down (mf/use-fn
+ (mf/deps on-click)
+ (fn [event]
+ (when (and on-click (or (kbd/enter? event) (kbd/space? event)))
+ (.preventDefault event)
+ (on-click event))))
+ props (mf/spread-props props
+ {:class [class (stl/css-case :change-row true
+ :is-selected is-selected
+ :is-muted is-muted
+ :tone-accent (= tone "accent")
+ :tone-info (= tone "info")
+ :tone-warning (= tone "warning")
+ :tone-error (= tone "error")
+ :tone-success (= tone "success")
+ :tone-purple (= tone "purple"))]
+ :role "button"
+ :tab-index 0
+ :aria-pressed is-selected
+ :on-click on-click
+ :on-key-down on-key-down})]
+ [:> :div props
+ [:span {:class (stl/css :bar) :aria-hidden true}]
+ [:div {:class (stl/css :card)}
+ (when start
+ [:div {:class (stl/css :start)} start])
+ (when icon
+ [:> icon* {:icon-id icon :size "s" :class (stl/css :icon)}])
+ [:div {:class (stl/css :body)}
+ [:> text* {:as "span" :typography t/body-small :class (stl/css :title)} title]
+ (when subtitle
+ [:> text* {:as "span" :typography t/body-small :class (stl/css :subtitle)} subtitle])]
+ (when end
+ [:div {:class (stl/css :end)} end])]]))
diff --git a/frontend/src/app/main/ui/ds/product/change_row.mdx b/frontend/src/app/main/ui/ds/product/change_row.mdx
new file mode 100644
index 0000000000..87940f9b96
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/change_row.mdx
@@ -0,0 +1,44 @@
+{ /* This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ Copyright (c) KALEIDOS SUBSIDIARY SL */ }
+
+import { Canvas, Meta } from '@storybook/addon-docs/blocks';
+import * as ChangeRow from "./change_row.stories";
+
+
+
+# ChangeRow
+
+One entry of a change list: a coloured bar for its status, an icon, a
+title over a one-line summary, and a slot at each end.
+
+
+
+## Variants
+
+`end` takes a tag or a count.
+
+
+
+`start` takes a checkbox or a chevron. `is-muted` dims a row that is
+left out.
+
+
+
+## Technical notes
+
+The row acts as a button (`role="button"`, Enter and Space call
+`on-click`). Controls in the slots must stop the click from reaching
+the row.
+
+```clj
+[:> change-row* {:title (:label item)
+ :subtitle summary
+ :icon i/board
+ :tone "info"
+ :is-selected selected?
+ :end (mf/html [:> tag* {:variant "pill"} "Added"])
+ :on-click on-select}]
+```
diff --git a/frontend/src/app/main/ui/ds/product/change_row.scss b/frontend/src/app/main/ui/ds/product/change_row.scss
new file mode 100644
index 0000000000..ecb868c1ff
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/change_row.scss
@@ -0,0 +1,119 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS SUBSIDIARY SL
+
+@use "ds/_borders.scss" as *;
+@use "ds/_utils.scss" as *;
+@use "ds/mixins.scss" as *;
+
+.change-row {
+ --row-bar-color: var(--color-foreground-secondary);
+ --row-bg-color: var(--color-background-tertiary);
+ --row-title-color: var(--color-foreground-primary);
+
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xxs);
+ cursor: pointer;
+
+ &:hover,
+ &.is-selected {
+ --row-bg-color: var(--color-background-quaternary);
+ }
+
+ &:focus-visible {
+ outline: none;
+ }
+
+ &:focus-visible .card {
+ outline: $b-1 solid var(--color-accent-primary);
+ }
+
+ &.is-muted {
+ --row-title-color: var(--color-foreground-secondary);
+ }
+
+ &.tone-accent {
+ --row-bar-color: var(--color-accent-primary);
+ }
+
+ &.tone-info {
+ --row-bar-color: var(--color-accent-info);
+ }
+
+ &.tone-warning {
+ --row-bar-color: var(--color-accent-warning);
+ }
+
+ &.tone-error {
+ --row-bar-color: var(--color-accent-error);
+ }
+
+ &.tone-success {
+ --row-bar-color: var(--color-accent-success);
+ }
+
+ &.tone-purple {
+ --row-bar-color: var(--color-accent-secondary);
+ }
+}
+
+.bar {
+ flex-shrink: 0;
+ inline-size: px2rem(3);
+ block-size: px2rem(30);
+ border-radius: px2rem(2);
+ background-color: var(--row-bar-color);
+}
+
+.card {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ gap: var(--sp-s);
+ min-inline-size: 0;
+ min-block-size: px2rem(46);
+ padding-block: var(--sp-xs);
+ padding-inline: var(--sp-s);
+ border-radius: $br-8;
+ background-color: var(--row-bg-color);
+}
+
+.start {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+}
+
+.icon {
+ flex-shrink: 0;
+ color: var(--color-foreground-secondary);
+}
+
+.body {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-inline-size: 0;
+}
+
+.title {
+ @include text-ellipsis;
+
+ color: var(--row-title-color);
+}
+
+.subtitle {
+ @include two-line-text-ellipsis;
+
+ color: var(--color-foreground-secondary);
+}
+
+.end {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: var(--sp-xs);
+}
diff --git a/frontend/src/app/main/ui/ds/product/change_row.stories.jsx b/frontend/src/app/main/ui/ds/product/change_row.stories.jsx
new file mode 100644
index 0000000000..b91b179bfc
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/change_row.stories.jsx
@@ -0,0 +1,67 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS SUBSIDIARY SL
+
+import * as React from "react";
+import Components from "@target/components";
+
+const { ChangeRow, Tag, Checkbox } = Components;
+const tones = [
+ "neutral",
+ "accent",
+ "info",
+ "warning",
+ "error",
+ "success",
+ "purple",
+];
+
+export default {
+ title: "Product/Branching/ChangeRow",
+ component: ChangeRow,
+ argTypes: {
+ tone: { options: tones, control: { type: "select" } },
+ isSelected: { control: { type: "boolean" } },
+ isMuted: { control: { type: "boolean" } },
+ },
+ args: {
+ title: "Plans / row",
+ subtitle: "Spaced out: gap 8 → 24",
+ icon: "board",
+ tone: "info",
+ isSelected: false,
+ isMuted: false,
+ },
+ render: ({ ...args }) => (
+
+
+
+ ),
+};
+
+export const Default = {};
+
+export const WithTag = {
+ args: {
+ title: "Card / Promo",
+ subtitle: "Corner radius - both changed",
+ tone: "warning",
+ end: (
+
+ Choose
+
+ ),
+ },
+};
+
+export const WithCheckbox = {
+ args: {
+ title: "Promo banner",
+ subtitle: "New board - left out",
+ tone: "success",
+ isMuted: true,
+ start: ,
+ },
+};
diff --git a/frontend/src/app/main/ui/ds/product/filter_chip.cljs b/frontend/src/app/main/ui/ds/product/filter_chip.cljs
new file mode 100644
index 0000000000..c727dbd376
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/filter_chip.cljs
@@ -0,0 +1,47 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
+
+(ns app.main.ui.ds.product.filter-chip
+ (:require-macros
+ [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
+ [app.main.ui.ds.product.tag :as tag]
+ [rumext.v2 :as mf]))
+
+(def ^:private schema:filter-chip
+ [:map
+ [:class {:optional true} [:maybe :string]]
+ [:label :string]
+ [:icon {:optional true} [:maybe [:and :string [:fn #(contains? i/icon-list %)]]]]
+ [:tone {:optional true} [:maybe (into [:enum] tag/tones)]]
+ [:is-selected {:optional true} [:maybe :boolean]]])
+
+(mf/defc filter-chip*
+ "A rounded toggle that filters a list by one category, with an
+ optional icon before its label. Selected, a toned chip draws its
+ outline, text and icon in its tone; a neutral one in the accent
+ colour."
+ {::mf/schema schema:filter-chip}
+ [{:keys [class label icon tone is-selected] :rest props}]
+ (let [tone (d/nilv tone "neutral")
+ is-selected (d/nilv is-selected false)
+ props (mf/spread-props props
+ {:class [class (stl/css-case :filter-chip true
+ :is-selected is-selected
+ :tone-accent (= tone "accent")
+ :tone-info (= tone "info")
+ :tone-warning (= tone "warning")
+ :tone-error (= tone "error")
+ :tone-success (= tone "success")
+ :tone-purple (= tone "purple"))]
+ :type "button"
+ :aria-pressed is-selected})]
+ [:> :button props
+ (when icon
+ [:> icon* {:icon-id icon :size "s" :class (stl/css :icon)}])
+ label]))
diff --git a/frontend/src/app/main/ui/ds/product/filter_chip.mdx b/frontend/src/app/main/ui/ds/product/filter_chip.mdx
new file mode 100644
index 0000000000..f2025f0afe
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/filter_chip.mdx
@@ -0,0 +1,43 @@
+{ /* This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ Copyright (c) KALEIDOS SUBSIDIARY SL */ }
+
+import { Canvas, Meta } from '@storybook/addon-docs/blocks';
+import * as FilterChip from "./filter_chip.stories";
+
+
+
+# FilterChip
+
+A rounded toggle that filters a list by one category.
+
+
+
+## Variants
+
+The selected chip draws its outline, text and icon in one colour: its
+tone when it has one, the accent colour when it is neutral.
+
+
+
+`icon` goes before the label, to tell neutral chips apart without
+colour.
+
+
+
+A row of chips filters the same list.
+
+
+
+## Technical notes
+
+It renders a `button` with `aria-pressed`. `label` is mandatory; any
+other prop goes to the button.
+
+```clj
+[:> filter-chip* {:label "Copy (1)" :icon i/text
+ :is-selected (= filter :copy)
+ :on-click on-filter}]
+```
diff --git a/frontend/src/app/main/ui/ds/product/filter_chip.scss b/frontend/src/app/main/ui/ds/product/filter_chip.scss
new file mode 100644
index 0000000000..c90d1aac5f
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/filter_chip.scss
@@ -0,0 +1,90 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS SUBSIDIARY SL
+
+@use "ds/_borders.scss" as *;
+@use "ds/_sizes.scss" as *;
+@use "ds/typography.scss" as *;
+
+.filter-chip {
+ --chip-fg-color: var(--color-foreground-secondary);
+ --chip-bg-color: var(--color-background-quaternary);
+ --chip-border-color: transparent;
+ --chip-selected-color: var(--color-accent-primary);
+
+ @include use-typography("body-small");
+
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: var(--sp-xs);
+
+ // the box of a small DS icon (16) plus the padding and the border, so
+ // chips with and without an icon share one height
+ min-block-size: calc(#{$sz-16} + var(--sp-xxs) * 2 + #{$b-1} * 2);
+ padding: var(--sp-xxs) var(--sp-s);
+ border: $b-1 solid var(--chip-border-color);
+ border-radius: $br-12;
+ background-color: var(--chip-bg-color);
+ color: var(--chip-fg-color);
+ white-space: nowrap;
+ cursor: pointer;
+
+ &:hover {
+ --chip-border-color: var(--color-foreground-secondary);
+ }
+
+ &:focus-visible {
+ outline: $b-1 solid var(--color-accent-primary);
+ }
+
+ &.tone-accent {
+ --chip-fg-color: var(--color-accent-primary);
+ --chip-selected-color: var(--color-accent-primary);
+ --chip-bg-color: var(--color-accent-primary-muted);
+ }
+
+ &.tone-info {
+ --chip-fg-color: var(--color-accent-info);
+ --chip-selected-color: var(--color-accent-info);
+ --chip-bg-color: var(--color-background-info);
+ }
+
+ &.tone-warning {
+ --chip-fg-color: var(--color-accent-warning);
+ --chip-selected-color: var(--color-accent-warning);
+ --chip-bg-color: var(--color-background-warning);
+ }
+
+ &.tone-error {
+ --chip-fg-color: var(--color-accent-error);
+ --chip-selected-color: var(--color-accent-error);
+ --chip-bg-color: var(--color-background-error);
+ }
+
+ &.tone-success {
+ --chip-fg-color: var(--color-accent-success);
+ --chip-selected-color: var(--color-accent-success);
+ --chip-bg-color: var(--color-background-success);
+ }
+
+ &.tone-purple {
+ --chip-fg-color: var(--color-accent-secondary);
+ --chip-selected-color: var(--color-accent-secondary);
+
+ // TODO: no DS background token pairs with the secondary accent
+ --chip-bg-color: color-mix(in srgb, var(--color-accent-secondary) 18%, transparent);
+ }
+
+ // the selected chip reads in its outline colour, text and icon too
+ &.is-selected {
+ --chip-border-color: var(--chip-selected-color);
+ --chip-fg-color: var(--chip-selected-color);
+ }
+}
+
+.icon {
+ flex-shrink: 0;
+}
diff --git a/frontend/src/app/main/ui/ds/product/filter_chip.stories.jsx b/frontend/src/app/main/ui/ds/product/filter_chip.stories.jsx
new file mode 100644
index 0000000000..0c1a85f27e
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/filter_chip.stories.jsx
@@ -0,0 +1,50 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS SUBSIDIARY SL
+
+import * as React from "react";
+import Components from "@target/components";
+
+const { FilterChip } = Components;
+const tones = [
+ "neutral",
+ "accent",
+ "info",
+ "warning",
+ "error",
+ "success",
+ "purple",
+];
+
+export default {
+ title: "Product/Branching/FilterChip",
+ component: FilterChip,
+ argTypes: {
+ tone: { options: tones, control: { type: "select" } },
+ isSelected: { control: { type: "boolean" } },
+ },
+ args: { label: "Decisions (2)", tone: "warning", isSelected: false },
+ render: ({ ...args }) => ,
+};
+
+export const Default = {};
+
+export const Selected = { args: { isSelected: true } };
+
+export const WithIcon = {
+ args: { label: "Copy (1)", icon: "text", tone: "neutral" },
+};
+
+export const Bar = {
+ render: () => (
+
+
+
+
+
+
+
+ ),
+};
diff --git a/frontend/src/app/main/ui/ds/product/preview_card.cljs b/frontend/src/app/main/ui/ds/product/preview_card.cljs
new file mode 100644
index 0000000000..19d82d786f
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/preview_card.cljs
@@ -0,0 +1,53 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
+
+(ns app.main.ui.ds.product.preview-card
+ (:require-macros
+ [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.main.ui.ds.foundations.typography :as t]
+ [app.main.ui.ds.foundations.typography.text :refer [text*]]
+ [rumext.v2 :as mf]))
+
+(def ^:private schema:preview-card
+ [:map
+ [:class {:optional true} [:maybe :string]]
+ [:label :string]
+ [:side {:optional true} [:maybe [:enum "base" "main" "branch" "result"]]]
+ [:detail {:optional true} [:maybe :string]]
+ [:caption {:optional true} [:maybe :string]]
+ [:is-warning {:optional true} [:maybe :boolean]]
+ [:is-large {:optional true} [:maybe :boolean]]])
+
+(mf/defc preview-card*
+ "A framed preview of one side of a comparison. The side picks the
+ colour of the frame and of the label: base is neutral,
+ main purple, the branch the accent and the merged result green.
+ `detail` follows the label (who or when); `caption` sits under the
+ preview, in the warning colour when `is-warning` says so. `is-large`
+ gives the preview a taller stage, for a view that has the room."
+ {::mf/schema schema:preview-card}
+ [{:keys [class label side detail caption is-warning is-large children] :rest props}]
+ (let [side (d/nilv side "base")
+ is-warning (d/nilv is-warning false)
+ is-large (d/nilv is-large false)
+ props (mf/spread-props props
+ {:class [class (stl/css-case :preview-card true
+ :side-main (= side "main")
+ :side-branch (= side "branch")
+ :side-result (= side "result"))]})]
+ [:> :div props
+ [:div {:class (stl/css :head)}
+ [:> text* {:as "span" :typography t/headline-small :class (stl/css :label)} label]
+ (when detail
+ [:> text* {:as "span" :typography t/body-small :class (stl/css :detail)} detail])]
+ [:div {:class (stl/css-case :stage true :is-large is-large)} children]
+ (when caption
+ [:> text* {:as "span"
+ :typography t/body-small
+ :class (stl/css-case :caption true :is-warning is-warning)}
+ caption])]))
diff --git a/frontend/src/app/main/ui/ds/product/preview_card.mdx b/frontend/src/app/main/ui/ds/product/preview_card.mdx
new file mode 100644
index 0000000000..c685bfee9e
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/preview_card.mdx
@@ -0,0 +1,49 @@
+{ /* This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ Copyright (c) KALEIDOS SUBSIDIARY SL */ }
+
+import { Canvas, Meta } from '@storybook/addon-docs/blocks';
+import * as PreviewCard from "./preview_card.stories";
+
+
+
+# PreviewCard
+
+A framed preview of one side of a comparison, labelled with the side
+it shows.
+
+
+
+## Variants
+
+The side colours the frame and the label: base neutral, main purple, the
+branch the accent, the merged result green.
+
+
+
+`caption` sits under the preview; `is-warning` draws it in the error
+colour.
+
+
+
+`is-large` gives the preview a taller stage, for a view with room to
+spare (the expanded comparison).
+
+
+
+## Technical notes
+
+The stage is 100 px high, 320 px with `is-large`. A container that
+knows how much room it has can set `--preview-card-stage-block-size`
+and `--preview-card-stage-large-block-size` to change them.
+
+The children fill the dark stage in the middle, centred.
+
+```clj
+[:> preview-card* {:label (tr "workspace.branches.review.side.main")
+ :side "main"
+ :detail main-ago}
+ [:> entity-preview* {:kind :shape :value main}]]
+```
diff --git a/frontend/src/app/main/ui/ds/product/preview_card.scss b/frontend/src/app/main/ui/ds/product/preview_card.scss
new file mode 100644
index 0000000000..8e9c86836a
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/preview_card.scss
@@ -0,0 +1,85 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS SUBSIDIARY SL
+
+@use "ds/_borders.scss" as *;
+@use "ds/_sizes.scss" as *;
+@use "ds/_utils.scss" as *;
+@use "ds/mixins.scss" as *;
+
+.preview-card {
+ --card-side-color: var(--color-foreground-secondary);
+
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-s);
+ min-inline-size: 0;
+ padding: var(--sp-m);
+ border: $b-1 solid var(--card-side-color);
+ border-radius: $br-8;
+ background-color: var(--color-background-tertiary);
+
+ &.side-main {
+ --card-side-color: var(--color-accent-secondary);
+ }
+
+ &.side-branch {
+ --card-side-color: var(--color-accent-primary);
+ }
+
+ &.side-result {
+ --card-side-color: var(--color-accent-success);
+ }
+}
+
+.head {
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ min-inline-size: 0;
+}
+
+.label {
+ flex-shrink: 0;
+ color: var(--card-side-color);
+}
+
+.detail {
+ @include text-ellipsis;
+
+ color: var(--color-foreground-secondary);
+}
+
+.stage {
+ // the checkerboard of a transparent background, the same in both
+ // themes (the DS swatch draws its transparency the same way), so
+ // white, black and grey shapes all stand out from it
+ --stage-check-color: color-mix(in srgb, var(--color-canvas) 70%, var(--color-static-white));
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ // a container that knows its room sets these two
+ block-size: var(--preview-card-stage-block-size, #{px2rem(100)});
+ padding: var(--sp-s);
+ overflow: hidden;
+ border-radius: $br-6;
+ background-color: var(--color-static-white);
+ background-image: repeating-conic-gradient(var(--stage-check-color) 0% 25%, transparent 0% 50%);
+ background-size: px2rem(20) px2rem(20);
+
+ &.is-large {
+ block-size: var(--preview-card-stage-large-block-size, #{px2rem(320)});
+ }
+}
+
+.caption {
+ color: var(--color-foreground-secondary);
+
+ &.is-warning {
+ color: var(--color-accent-error);
+ }
+}
diff --git a/frontend/src/app/main/ui/ds/product/preview_card.stories.jsx b/frontend/src/app/main/ui/ds/product/preview_card.stories.jsx
new file mode 100644
index 0000000000..bddddcb2c6
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/preview_card.stories.jsx
@@ -0,0 +1,83 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS SUBSIDIARY SL
+
+import * as React from "react";
+import Components from "@target/components";
+
+const { PreviewCard } = Components;
+
+const Sample = ({ text }) => (
+
+ ),
+};
+
+export const Large = {
+ args: { isLarge: true },
+};
+
+export const WithCaption = {
+ args: {
+ label: "Result",
+ side: "result",
+ detail: "Merged as stored",
+ caption: "Card 4 from main lands on card 3.",
+ isWarning: true,
+ },
+};
diff --git a/frontend/src/app/main/ui/ds/product/tag.cljs b/frontend/src/app/main/ui/ds/product/tag.cljs
new file mode 100644
index 0000000000..3eb7fb7fdb
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/tag.cljs
@@ -0,0 +1,47 @@
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS SUBSIDIARY SL
+
+(ns app.main.ui.ds.product.tag
+ (:require-macros
+ [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
+ [rumext.v2 :as mf]))
+
+(def tones
+ #{"neutral" "accent" "info" "warning" "error" "success" "purple"})
+
+(def ^:private schema:tag
+ [:map
+ [:class {:optional true} [:maybe :string]]
+ [:icon {:optional true} [:maybe [:and :string [:fn #(contains? i/icon-list %)]]]]
+ [:tone {:optional true} [:maybe (into [:enum] tones)]]
+ [:variant {:optional true} [:maybe [:enum "label" "pill"]]]])
+
+(mf/defc tag*
+ "A short, static status word. The `label` variant is a small code-font
+ tag that names what something is (Board, Verified, Clash);
+ the `pill` variant is a rounded word or count that sits at the end of a
+ row (Choose, Decided, 3 edits). The tone colours both; `icon` goes
+ before the text."
+ {::mf/schema schema:tag}
+ [{:keys [class icon tone variant children] :rest props}]
+ (let [tone (d/nilv tone "neutral")
+ variant (d/nilv variant "label")
+ props (mf/spread-props props
+ {:class [class (stl/css-case :tag true
+ :is-pill (= variant "pill")
+ :tone-accent (= tone "accent")
+ :tone-info (= tone "info")
+ :tone-warning (= tone "warning")
+ :tone-error (= tone "error")
+ :tone-success (= tone "success")
+ :tone-purple (= tone "purple"))]})]
+ [:> :span props
+ (when icon
+ [:> icon* {:icon-id icon :size "s"}])
+ children]))
diff --git a/frontend/src/app/main/ui/ds/product/tag.mdx b/frontend/src/app/main/ui/ds/product/tag.mdx
new file mode 100644
index 0000000000..f54fba84f9
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/tag.mdx
@@ -0,0 +1,47 @@
+{ /* This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ Copyright (c) KALEIDOS SUBSIDIARY SL */ }
+
+import { Canvas, Meta } from '@storybook/addon-docs/blocks';
+import * as Tag from "./tag.stories";
+
+
+
+# Tag
+
+A short, static status word that says what something is or where it
+stands.
+
+
+
+## Variants
+
+`label` (the default) is a small code-font tag that names a type or a
+state: Board, Main component, Verified, Clash.
+
+`pill` is a rounded word or count at the end of a row: Choose, Decided,
+3 edits, 14 shapes.
+
+
+
+The tone colours the text and its background. Text keeps the case it
+is written in; the tag never turns it uppercase.
+
+
+
+`icon` goes before the text.
+
+
+
+## Technical notes
+
+```clj
+[:> tag* {:tone "warning"} (tr "workspace.branches.review.tag.clash")]
+[:> tag* {:tone "success" :variant "pill"} (tr "workspace.branches.review.decided")]
+```
+
+## Usage guidelines (design)
+
+Use it for a word that does not act. For a filter, use `FilterChip`.
diff --git a/frontend/src/app/main/ui/ds/product/tag.scss b/frontend/src/app/main/ui/ds/product/tag.scss
new file mode 100644
index 0000000000..3be7be8190
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/tag.scss
@@ -0,0 +1,71 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS SUBSIDIARY SL
+
+@use "ds/_borders.scss" as *;
+@use "ds/_sizes.scss" as *;
+@use "ds/_utils.scss" as *;
+@use "ds/typography.scss" as *;
+
+.tag {
+ --tag-fg-color: var(--color-foreground-secondary);
+ --tag-bg-color: var(--color-background-quaternary);
+
+ @include use-typography("code-font");
+
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: var(--sp-xxs);
+
+ // the box of a small DS icon (16) plus the padding, so a tag with an
+ // icon and one without stand the same height side by side
+ min-block-size: calc(#{$sz-16} + var(--sp-xxs) * 2);
+ padding: var(--sp-xxs) var(--sp-xs);
+ border-radius: $br-4;
+ background-color: var(--tag-bg-color);
+ color: var(--tag-fg-color);
+ font-size: px2rem(10);
+ white-space: nowrap;
+
+ &.is-pill {
+ @include use-typography("body-small");
+
+ padding: var(--sp-xxs) var(--sp-s);
+ border-radius: $br-12;
+ }
+
+ &.tone-accent {
+ --tag-fg-color: var(--color-accent-primary);
+ --tag-bg-color: var(--color-accent-primary-muted);
+ }
+
+ &.tone-info {
+ --tag-fg-color: var(--color-accent-info);
+ --tag-bg-color: var(--color-background-info);
+ }
+
+ &.tone-warning {
+ --tag-fg-color: var(--color-accent-warning);
+ --tag-bg-color: var(--color-background-warning);
+ }
+
+ &.tone-error {
+ --tag-fg-color: var(--color-accent-error);
+ --tag-bg-color: var(--color-background-error);
+ }
+
+ &.tone-success {
+ --tag-fg-color: var(--color-accent-success);
+ --tag-bg-color: var(--color-background-success);
+ }
+
+ &.tone-purple {
+ --tag-fg-color: var(--color-accent-secondary);
+
+ // TODO: no DS background token pairs with the secondary accent
+ --tag-bg-color: color-mix(in srgb, var(--color-accent-secondary) 18%, transparent);
+ }
+}
diff --git a/frontend/src/app/main/ui/ds/product/tag.stories.jsx b/frontend/src/app/main/ui/ds/product/tag.stories.jsx
new file mode 100644
index 0000000000..5a005c2b1b
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/product/tag.stories.jsx
@@ -0,0 +1,57 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS SUBSIDIARY SL
+
+import * as React from "react";
+import Components from "@target/components";
+
+const { Tag } = Components;
+const tones = [
+ "neutral",
+ "accent",
+ "info",
+ "warning",
+ "error",
+ "success",
+ "purple",
+];
+
+export default {
+ title: "Product/Branching/Tag",
+ component: Tag,
+ argTypes: {
+ tone: { options: tones, control: { type: "select" } },
+ variant: { options: ["label", "pill"], control: { type: "radio" } },
+ },
+ args: { tone: "neutral", variant: "label", children: "Board" },
+ render: ({ ...args }) => ,
+};
+
+export const Default = {};
+
+export const Pill = {
+ args: { variant: "pill", tone: "warning", children: "Choose" },
+};
+
+export const WithIcon = {
+ args: { icon: "component", children: "Components" },
+};
+
+export const Tones = {
+ render: () => (
+