Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions scss/_chat.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
@use "config" as *;
@use "functions" as *;
@use "mixins/border-radius" as *;
@use "mixins/tokens" as *;

$chat-tokens: () !default;

// stylelint-disable custom-property-no-missing-var-function
// scss-docs-start chat-tokens
// stylelint-disable-next-line scss/dollar-variable-default
$chat-tokens: defaults(
(
--chat-bg: var(--bg-body),
--chat-border-color: var(--border-muted),
--chat-padding: 1rem,
--chat-gap: .75rem,
--chat-message-gap: .25rem,
--chat-message-max-width: 75%,
--chat-message-padding-x: .875rem,
--chat-message-padding-y: .5rem,
--chat-message-border-radius: var(--radius-8),
--chat-message-group-radius: var(--radius-2),
--chat-message-group-start-radius: var(--chat-message-group-radius),
--chat-message-group-end-radius: var(--chat-message-border-radius),
--chat-message-tail-size: 1rem,
--chat-message-color: var(--theme-fg, var(--fg-body)),
--chat-message-bg: var(--theme-bg-subtle, var(--bg-2)),
--chat-message-you-color: var(--theme-contrast, var(--primary-contrast)),
--chat-message-you-bg: var(--theme-bg, var(--primary-bg)),
--chat-status-gap: .5rem,
--chat-thinking-font-size: var(--font-size-sm),
--chat-thinking-color: var(--fg-4),
--chat-thinking-highlight-color: var(--fg-body),
--chat-thinking-animation-duration: 2s,
),
$chat-tokens
);
// scss-docs-end chat-tokens
// stylelint-enable custom-property-no-missing-var-function

@layer components {
.chat {
@include tokens($chat-tokens);

display: flex;
flex-direction: column;
gap: var(--chat-gap);
padding: var(--chat-padding);
background-color: var(--chat-bg);
border: var(--border-width) solid var(--chat-border-color);
@include border-radius(var(--radius-5));
}

.chat-row {
display: flex;
flex-direction: column;
gap: var(--chat-message-gap);
align-items: flex-start;
}

.chat-row-you {
align-items: flex-end;
}

.chat-message {
position: relative;
display: inline-block;
max-width: var(--chat-message-max-width);
padding: var(--chat-message-padding-y) var(--chat-message-padding-x);
color: var(--chat-message-color);
background-color: var(--chat-message-bg);
@include border-radius(var(--chat-message-border-radius));

&:last-child {
margin-bottom: calc(var(--chat-message-tail-size) * .5);
}

// Inside a group, tighten the corners that face the neighbor bubble. The
// sender side keeps the tight corners, as iOS does.
&:not(:first-child) {
border-start-start-radius: var(--chat-message-group-start-radius);
border-start-end-radius: var(--chat-message-group-end-radius);
}

&:not(:last-child) {
border-end-start-radius: var(--chat-message-group-start-radius);
border-end-end-radius: var(--chat-message-group-end-radius);
}
}

// Message tail
//
// The ::before element paints the tail in the bubble color. The ::after
// element cuts the notch back into the panel background so the tail
// connects to the last bubble of the group.
//
// Logical properties keep the tail on the sender's edge, so it stays attached
// to the bubble in both writing directions.
.chat-message:last-child::before,
.chat-message:last-child::after {
position: absolute;
inset-block-end: 0;
content: "";
}

.chat-message:last-child::before {
inset-inline-start: calc(var(--chat-message-tail-size) * -.35);
z-index: 0;
width: var(--chat-message-tail-size);
height: var(--chat-message-tail-size);
background-color: var(--chat-message-bg);
@include border-bottom-end-radius(calc(var(--chat-message-tail-size) * .75));
}

.chat-message:last-child::after {
inset-inline-start: calc(var(--chat-message-tail-size) * -.5);
z-index: 1;
width: calc(var(--chat-message-tail-size) * .5);
height: var(--chat-message-tail-size);
background-color: var(--chat-bg);
@include border-bottom-end-radius(calc(var(--chat-message-tail-size) * .5));
}

.chat-message-you {
--chat-message-bg: var(--chat-message-you-bg);
--chat-message-color: var(--chat-message-you-color);
--chat-message-group-start-radius: var(--chat-message-border-radius);
--chat-message-group-end-radius: var(--chat-message-group-radius);

&:last-child::before {
inset-inline-start: auto;
inset-inline-end: calc(var(--chat-message-tail-size) * -.35);
border-end-end-radius: 0;
@include border-bottom-start-radius(calc(var(--chat-message-tail-size) * .75));
}

&:last-child::after {
inset-inline-start: auto;
inset-inline-end: calc(var(--chat-message-tail-size) * -.5);
border-end-end-radius: 0;
@include border-bottom-start-radius(calc(var(--chat-message-tail-size) * .5));
}
}

// Status rows
//
// Thinking states and tool calls report on the work, so they stay outside a
// bubble. The inline padding lines their text up with the text in a bubble.
.chat-status {
display: flex;
flex-wrap: wrap;
gap: var(--chat-status-gap);
align-items: center;
padding-inline: var(--chat-message-padding-x);
}

// A gradient sweeps across the label while the agent works. The text is
// transparent, so the background paints the glyphs.
.chat-thinking {
font-size: var(--chat-thinking-font-size);
color: transparent;
background-image:
linear-gradient(
90deg,
var(--chat-thinking-color) 35%,
var(--chat-thinking-highlight-color) 50%,
var(--chat-thinking-color) 65%
);
background-clip: text;
background-size: 200% 100%;
animation: chat-thinking var(--chat-thinking-animation-duration) linear infinite;

@if $enable-reduced-motion {
// The glyphs come from the background, so the label needs a color back
// when the sweep stops.
@media (prefers-reduced-motion: reduce) {
color: var(--chat-thinking-highlight-color);
background-image: none;
animation: none;
}
}
}

// The gradient tiles, and one iteration moves it by its own width, so the
// sweep repeats without a jump.
@keyframes chat-thinking {
to {
background-position: -200% 0;
}
}
}
167 changes: 167 additions & 0 deletions scss/_histogram.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
@use "config" as *;
@use "functions" as *;
@use "mixins/border-radius" as *;
@use "mixins/focus-ring" as *;
@use "mixins/transition" as *;
@use "mixins/tokens" as *;

// Number of items that get a staggered reveal. Later items reveal with the
// same delay as the last one in the sequence.
$histogram-stagger-items: 12 !default;

$histogram-tokens: () !default;

// scss-docs-start histogram-tokens
// stylelint-disable-next-line scss/dollar-variable-default
$histogram-tokens: defaults(
(
--histogram-track-width: 2.5rem,
--histogram-item-height: .75rem,
--histogram-item-hover-height: 1.5rem,
--histogram-item-hover-color: var(--fg-body),
--histogram-item-active-color: var(--theme-fg, var(--primary-fg)),
--histogram-bar-length: 100%,
--histogram-bar-size: .125rem,
--histogram-bar-border-radius: var(--radius-pill),
--histogram-bar-bg: var(--fg-4),
--histogram-bar-active-bg: var(--theme-bg, var(--primary-bg)),
--histogram-label-color: var(--fg-2),
--histogram-label-font-size: var(--font-size-sm),
--histogram-stagger: 30ms,
--histogram-transition-property: "block-size, color, opacity, background-color",
--histogram-transition-duration: .2s,
--histogram-transition-timing: var(--transition-timing-overlay),
),
$histogram-tokens
);
// scss-docs-end histogram-tokens

@layer components {
.histogram {
@include tokens($histogram-tokens);

display: flex;
flex-direction: column;
}

// Each item stacks a bar and a label in the same grid cell, so the label
// takes over the row without moving anything. The bar keeps its own column
// so its length stays a share of the track, not of the label.
.histogram-item {
// Items past the stagger cap match no `nth-child` rule below, so they fall
// back to this max delay and reveal with the last staggered row instead of
// jumping to the front.
--histogram-item-delay: calc(#{$histogram-stagger-items - 1} * var(--histogram-stagger));

display: grid;
grid-template-columns: var(--histogram-track-width) minmax(0, 1fr);
align-items: center;
block-size: var(--histogram-item-height);
padding: 0;
color: var(--histogram-label-color);
text-align: start;
text-decoration: none;
background-color: transparent;
border: 0;
@include transition-props(
var(--histogram-transition-property),
var(--histogram-transition-duration),
var(--histogram-transition-timing)
);

&:hover {
color: var(--histogram-item-hover-color);
}

&:focus-visible {
@include focus-ring(true);
}

&.active {
color: var(--histogram-item-active-color);

.histogram-bar {
background-color: var(--histogram-bar-active-bg);
}
}
}

.histogram-bar {
grid-area: 1 / 1;
inline-size: var(--histogram-bar-length);
block-size: var(--histogram-bar-size);
background-color: var(--histogram-bar-bg);
@include border-radius(var(--histogram-bar-border-radius));
@include transition-props(
var(--histogram-transition-property),
var(--histogram-transition-duration),
var(--histogram-transition-timing)
);
}

.histogram-label {
grid-area: 1 / 1 / 2 / -1;
overflow: hidden;
font-size: var(--histogram-label-font-size);
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0;
@include transition-props(
var(--histogram-transition-property),
var(--histogram-transition-duration),
var(--histogram-transition-timing)
);
}

// Bars trade places with labels as soon as a pointer or the keyboard enters
// the histogram. Rows grow at the same time to make room for the text.
.histogram:hover,
.histogram:focus-within {
.histogram-item {
block-size: var(--histogram-item-hover-height);
}

.histogram-bar {
opacity: 0;
}

.histogram-label {
opacity: 1;

// The delay drives the staggered reveal, so it must follow the same
// reduced-motion gate as the transition itself. Otherwise the labels
// still pop in one by one for a user who asked for less motion.
@if $enable-reduced-motion {
@media (prefers-reduced-motion: no-preference) {
transition-delay: var(--histogram-item-delay, 0s);
}
} @else {
transition-delay: var(--histogram-item-delay, 0s);
}
}
}

// Reveal the labels top to bottom instead of all at once. Only the reveal
// is staggered — leaving the histogram collapses every row together.
@for $i from 1 through $histogram-stagger-items {
.histogram-item:nth-child(#{$i}) {
--histogram-item-delay: calc(#{$i - 1} * var(--histogram-stagger));
}
}

// Mirror the histogram for a sidebar on the end edge of the conversation.
.histogram-end {
.histogram-item {
grid-template-columns: minmax(0, 1fr) var(--histogram-track-width);
}

.histogram-bar {
grid-area: 1 / 2;
margin-inline-start: auto;
}

.histogram-label {
text-align: end;
}
}
}
2 changes: 2 additions & 0 deletions scss/bootstrap.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
@forward "chip";
@forward "card";
@forward "carousel";
@forward "chat";
@forward "datepicker";
@forward "dialog";
@forward "menu";
Expand All @@ -37,6 +38,7 @@
@forward "nav-overflow";
@forward "navbar";
@forward "drawer";
@forward "histogram";
@forward "pagination";
@forward "placeholder";
@forward "popover";
Expand Down
2 changes: 2 additions & 0 deletions site/data/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
examples:
- name: Album
description: 'Simple one-page template for photo galleries, portfolios, and more.'
- name: Agent
description: 'Agent chat interface built with chat bubbles, a histogram rail, chips, and spinners.'
- name: Pricing
description: 'Example pricing page built with Cards and featuring a custom header and footer.'
- name: Checkout
Expand Down
Loading