1124 lines
33 KiB
CSS
1124 lines
33 KiB
CSS
/* ==========================================================================
|
||
Doormile AI — Operations Copilot
|
||
--------------------------------------------------------------------------
|
||
Styling for the right-side slide-over assistant. Follows the same
|
||
convention as OrdersRedesign.css / Dispatch.css (a page-scoped stylesheet
|
||
imported by its own component) rather than inline styles, so the panel's
|
||
hover / focus / media-query / reduced-motion states are all expressible.
|
||
|
||
Every value below resolves to a design token (`--color-*`, `--spacing-*`,
|
||
`--radius-*`) or to a local `--dai-*` token defined once here. The local
|
||
tokens exist because the AI surface needs a few values the shared DT set
|
||
doesn't carry (the AI accent, the scrim, the panel elevation).
|
||
========================================================================== */
|
||
|
||
/* ---------------------------------------------------------------------------
|
||
Dock geometry.
|
||
|
||
These three live on :root, not on .dai-root, because the LAYOUT has to read
|
||
them: the assistant is docked into the page rather than floated over it, so
|
||
the app's content container shifts by exactly the panel's width and animates
|
||
on the same curve. A token only .dai-root can see cannot do that.
|
||
|
||
All three are --dai- prefixed. :root is shared with Dispatch.css and
|
||
globalPolish.css, and an unprefixed name there is decided by bundle order.
|
||
--------------------------------------------------------------------------- */
|
||
:root {
|
||
/* 30vw, NOT 30%.
|
||
|
||
The two consumers resolve a percentage against different boxes: the panel
|
||
is position:fixed, so `width: 30%` is 30% of the VIEWPORT, while
|
||
`padding-right: 30%` on the content container is 30% of ITS containing
|
||
block — which is narrower by the side nav. At 1440px that is 432px of
|
||
panel against 410px of reserved strip, and the page slides 22px underneath
|
||
the panel. A viewport unit resolves the same for both.
|
||
|
||
max() rather than a separate min-width for the same reason: a min-width
|
||
that only the panel knows about would reintroduce the mismatch on narrow
|
||
screens. */
|
||
/* Default = the NORMAL step. AIPanel overwrites this property on the
|
||
document element when the operator toggles the width, so the panel and
|
||
the page's reserved strip always read the same value. */
|
||
--dai-dock-width: max(300px, 25vw);
|
||
--dai-duration: 240ms;
|
||
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||
}
|
||
|
||
.dai-root {
|
||
/* Brand accent — resolves to the app's accent token, which is black
|
||
(root CLAUDE.md §6.2: "the brand is black", one accent only). Send
|
||
button, active states and the trigger all read from this single
|
||
variable, so switching the assistant to Doormile red is a one-line
|
||
change here rather than a hunt through the components. */
|
||
--dai-accent: var(--color-accent, #0f172a);
|
||
--dai-accent-contrast: #ffffff;
|
||
/* Focus ring, tinted with the accent rather than a flat grey wash. */
|
||
--dai-accent-ring: color-mix(in srgb, var(--dai-accent) 14%, transparent);
|
||
|
||
/* AI identity accent — used ONLY on the spark/orb marks so the assistant
|
||
reads as an AI surface without turning the panel into a purple product. */
|
||
--dai-ai-from: #6366f1;
|
||
--dai-ai-to: #8b5cf6;
|
||
--dai-ai-glow: rgba(99, 102, 241, 0.18);
|
||
|
||
--dai-text: #0f172a;
|
||
--dai-text-secondary: #64748b;
|
||
--dai-text-muted: #94a3b8;
|
||
--dai-surface: #ffffff;
|
||
--dai-surface-alt: #f8fafc;
|
||
--dai-surface-hover: #f1f5f9;
|
||
--dai-border: rgba(15, 23, 42, 0.08);
|
||
--dai-border-strong: rgba(15, 23, 42, 0.14);
|
||
--dai-scrim: rgba(15, 23, 42, 0.08);
|
||
--dai-shadow: 0 8px 30px rgba(15, 23, 42, 0.1);
|
||
--dai-live: #10b981;
|
||
|
||
--dai-panel-width: 428px;
|
||
--dai-inset: 12px;
|
||
--dai-duration: 240ms;
|
||
--dai-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Scrim — deliberately light. The dashboard underneath must stay readable;
|
||
this is a layering cue, not a modal blackout.
|
||
-------------------------------------------------------------------------- */
|
||
/* The scrim exists for the MOBILE presentation only (see the media query at
|
||
the end of this block). On desktop the panel is docked into the layout and
|
||
dimming the page behind it would be a lie — the page is still live, still
|
||
scrollable, and still the thing the operator is working on. Dimming is also
|
||
the single strongest cue that something is modal, which is exactly the
|
||
impression this panel should not give. */
|
||
.dai-scrim {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1300;
|
||
background: var(--dai-scrim);
|
||
opacity: 0;
|
||
transition: opacity var(--dai-duration) var(--dai-ease);
|
||
}
|
||
.dai-scrim[data-open='true'] {
|
||
opacity: 1;
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Panel
|
||
-------------------------------------------------------------------------- */
|
||
/* DOCKED, not floating.
|
||
|
||
It used to be an inset card: 12px off every edge, fully rounded, drop
|
||
shadowed, over a scrim — every cue of a popup. It now sits flush in the
|
||
right 30% of the workspace, starting under the app header and running to the
|
||
bottom of the window, with a single hairline on its left edge. The page does
|
||
not move underneath it; it makes room for it.
|
||
|
||
`top` reads Astryx's own measured header height so the dock lines up with
|
||
the bottom of the top nav rather than guessing a number.
|
||
|
||
No radius and no drop shadow: both are what make a surface read as floating
|
||
ABOVE the page. A soft shadow is kept only as a left-edge falloff so the
|
||
seam has depth without the panel detaching. */
|
||
.dai-panel {
|
||
position: fixed;
|
||
/* Set from JS by AIPanel — Astryx does not publish a header-height token,
|
||
despite --appshell-header-height looking like one. */
|
||
top: var(--dai-dock-top, 57px);
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 1200;
|
||
width: var(--dai-dock-width);
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
background: var(--dai-surface);
|
||
border-left: 1px solid var(--dai-border);
|
||
border-radius: 0;
|
||
box-shadow: -6px 0 20px rgba(15, 23, 42, 0.05);
|
||
transform: translateX(100%);
|
||
visibility: hidden;
|
||
transition:
|
||
transform var(--dai-duration) var(--dai-ease),
|
||
visibility 0s linear var(--dai-duration);
|
||
}
|
||
.dai-panel[data-open='true'] {
|
||
transform: translateX(0);
|
||
visibility: visible;
|
||
transition:
|
||
transform var(--dai-duration) var(--dai-ease),
|
||
visibility 0s;
|
||
}
|
||
|
||
/* ---- The page makes room -------------------------------------------------
|
||
`.dai-docked` is set on <body> while the panel is open. Padding rather than
|
||
width/margin: the content container is a flex child inside AppShell's own
|
||
height:fill chain, and changing its width there fights that chain, whereas
|
||
padding leaves the box model alone and simply reserves the strip.
|
||
|
||
The transition sits on the container unconditionally so the page slides back
|
||
when the panel closes too — a rule that only exists while `.dai-docked` is
|
||
applied cannot animate its own removal. */
|
||
.astryx-layout-content {
|
||
transition: padding-right var(--dai-duration) var(--dai-ease);
|
||
}
|
||
body.dai-docked .astryx-layout-content {
|
||
padding-right: var(--dai-dock-width);
|
||
}
|
||
|
||
/* ---- Mobile: there is no 30% worth having -------------------------------
|
||
30% of a phone is ~120px. Below the breakpoint the panel becomes what it
|
||
used to be everywhere — a full-width overlay with a scrim — and the page
|
||
stops reserving a strip it cannot afford. */
|
||
@media (max-width: 900px) {
|
||
.dai-panel {
|
||
top: 0;
|
||
width: 100%;
|
||
z-index: 1301;
|
||
border-left: none;
|
||
box-shadow: var(--dai-shadow);
|
||
}
|
||
.dai-scrim {
|
||
display: block;
|
||
}
|
||
.dai-scrim[data-open='true'] {
|
||
opacity: 1;
|
||
}
|
||
body.dai-docked .astryx-layout-content {
|
||
padding-right: 0;
|
||
}
|
||
}
|
||
.dai-panel:focus {
|
||
outline: none;
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Header
|
||
-------------------------------------------------------------------------- */
|
||
.dai-root .dai-header {
|
||
flex: 0 0 auto;
|
||
padding: 14px 12px 12px 14px;
|
||
border-bottom: 1px solid var(--dai-border);
|
||
}
|
||
.dai-root .dai-title {
|
||
font-size: 15px;
|
||
font-weight: 650;
|
||
line-height: 1.2;
|
||
letter-spacing: -0.01em;
|
||
color: var(--dai-text);
|
||
}
|
||
.dai-root .dai-subtitle {
|
||
font-size: 12px;
|
||
line-height: 1.3;
|
||
color: var(--dai-text-muted);
|
||
}
|
||
/* The AI mark. A soft gradient orb — not a robot face. */
|
||
.dai-root .dai-spark {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 auto;
|
||
border-radius: 999px;
|
||
background: var(--dai-surface);
|
||
overflow: hidden;
|
||
}
|
||
/* The mark's own canvas is only 66.8% content — a third of every edge is
|
||
transparent padding, measured off the PNG's alpha bounding box (x and y both
|
||
170..853 of 1024, i.e. perfectly centred). Drawing it at 100% therefore
|
||
rendered a D two-thirds the size the box implied, which is exactly why it
|
||
read as too small. 150% cancels that padding so the D fills its box edge to
|
||
edge; the overflow:hidden above clips only transparent pixels. */
|
||
.dai-root .dai-spark img {
|
||
width: 150%;
|
||
height: 150%;
|
||
max-width: none;
|
||
object-fit: contain;
|
||
display: block;
|
||
}
|
||
.dai-root .dai-spark[data-size='sm'] {
|
||
width: 30px;
|
||
height: 30px;
|
||
}
|
||
.dai-root .dai-spark[data-size='md'] {
|
||
width: 40px;
|
||
height: 40px;
|
||
}
|
||
.dai-root .dai-spark[data-size='lg'] {
|
||
width: 56px;
|
||
height: 56px;
|
||
}
|
||
/* Live indicator — subtle, not a large pill. */
|
||
.dai-root .dai-live {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 3px 8px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 550;
|
||
color: #047857;
|
||
background: rgba(16, 185, 129, 0.08);
|
||
white-space: nowrap;
|
||
}
|
||
.dai-root .dai-live-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 999px;
|
||
background: var(--dai-live);
|
||
animation: dai-pulse 2.4s ease-in-out infinite;
|
||
}
|
||
@keyframes dai-pulse {
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
50% {
|
||
opacity: 0.55;
|
||
transform: scale(0.85);
|
||
}
|
||
}
|
||
/* Page-context strip — "Orders · Today · All locations" */
|
||
.dai-root .dai-context {
|
||
flex: 0 0 auto;
|
||
padding: 7px 14px;
|
||
font-size: 11.5px;
|
||
color: var(--dai-text-muted);
|
||
background: var(--dai-surface-alt);
|
||
border-bottom: 1px solid var(--dai-border);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Welcome state
|
||
-------------------------------------------------------------------------- */
|
||
.dai-root .dai-welcome {
|
||
padding: 22px 14px 8px;
|
||
}
|
||
.dai-root .dai-welcome-greeting {
|
||
font-size: 17px;
|
||
font-weight: 650;
|
||
letter-spacing: -0.01em;
|
||
color: var(--dai-text);
|
||
}
|
||
.dai-root .dai-welcome-lead {
|
||
font-size: 13.5px;
|
||
line-height: 1.5;
|
||
color: var(--dai-text-secondary);
|
||
}
|
||
.dai-root .dai-welcome-note {
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: var(--dai-text-muted);
|
||
}
|
||
.dai-root .dai-section-label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
color: var(--dai-text-muted);
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Suggestion cards
|
||
-------------------------------------------------------------------------- */
|
||
/* The suggestions strip sits between the conversation and the composer, so it
|
||
needs the composer's horizontal rhythm and a hairline to separate it from
|
||
the thread scrolling above it. */
|
||
.dai-root .dai-suggestions-bar {
|
||
/* Shrinkable, with a hard cap.
|
||
|
||
At the normal 25% width the full twenty chips still stack to several rows.
|
||
As `flex: 0 0 auto` that block refuses to shrink, so on a short laptop it
|
||
pushes the composer off the bottom of the panel — the input disappears and
|
||
the assistant cannot be used at all. The cap is a safety valve, not a
|
||
layout choice: it only engages when the strip would otherwise cost more
|
||
than a third of the panel, and it never applies once a thread exists,
|
||
because the strip is four chips by then. */
|
||
flex: 0 1 auto;
|
||
min-height: 0;
|
||
max-height: 34vh;
|
||
overflow-y: auto;
|
||
padding: 10px 14px 8px;
|
||
border-top: 1px solid var(--dai-border);
|
||
background: var(--dai-surface);
|
||
}
|
||
|
||
/* Follow-up mode: a handful of chips, so the strip is FIXED — it never shrinks
|
||
and never scrolls.
|
||
|
||
The cap and `flex-shrink: 1` above exist for the cold panel's twenty chips.
|
||
Once a conversation starts the strip is three or four, and leaving it
|
||
shrinkable meant a long thread squeezed it: the flex row gave the space to
|
||
the conversation, `overflow-y: auto` quietly hid the remainder, and the
|
||
follow-ups the operator was meant to act on disappeared below a fold nobody
|
||
could see. Few chips must always be fully visible. */
|
||
.dai-root .dai-suggestions-bar[data-compact='true'] {
|
||
flex: 0 0 auto;
|
||
max-height: none;
|
||
overflow: visible;
|
||
}
|
||
|
||
/* ---- Suggested questions: chips ------------------------------------------
|
||
They wrap, they never scroll. A horizontal scroller can always leave a chip
|
||
half-visible at the edge, and a half-visible button is a bug no amount of
|
||
fade masking fixes. */
|
||
.dai-root .dai-suggestions {
|
||
width: 100%;
|
||
}
|
||
|
||
.dai-suggestion {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
max-width: 100%;
|
||
padding: 5px 10px;
|
||
text-align: left;
|
||
font: inherit;
|
||
font-size: 12px;
|
||
line-height: 1.35;
|
||
font-weight: 500;
|
||
color: var(--dai-text);
|
||
background: var(--dai-surface);
|
||
border: 1px solid var(--dai-border);
|
||
/* 14px, pinned — NOT 999px.
|
||
|
||
On a one-line chip a fully round radius already resolves to about 14px, so
|
||
these look identical. The difference shows on a chip whose question wraps
|
||
to two lines: 999px would resolve to half of ~46px and the chip stops
|
||
reading as a chip and starts reading as a card, so one long suggestion
|
||
would look like a different component from the nineteen beside it. */
|
||
border-radius: 14px;
|
||
cursor: pointer;
|
||
transition:
|
||
background-color 140ms ease,
|
||
border-color 140ms ease,
|
||
color 140ms ease,
|
||
transform 140ms ease;
|
||
}
|
||
.dai-suggestion:hover {
|
||
background: var(--dai-surface-alt);
|
||
border-color: var(--dai-border-strong);
|
||
transform: translateY(-1px);
|
||
}
|
||
.dai-suggestion:active {
|
||
transform: translateY(0);
|
||
}
|
||
.dai-suggestion:focus-visible {
|
||
outline: 2px solid var(--dai-accent);
|
||
outline-offset: 2px;
|
||
}
|
||
.dai-root .dai-suggestion-icon {
|
||
flex: 0 0 auto;
|
||
color: var(--dai-text-muted);
|
||
transition: color 140ms ease;
|
||
}
|
||
.dai-suggestion:hover .dai-suggestion-icon {
|
||
color: var(--dai-text-secondary);
|
||
}
|
||
.dai-root .dai-suggestion-text {
|
||
min-width: 0;
|
||
}
|
||
/* Plain text link-button ("View more", "Sources") */
|
||
.dai-link {
|
||
font: inherit;
|
||
font-size: 12px;
|
||
color: var(--dai-text-secondary);
|
||
background: none;
|
||
border: none;
|
||
padding: 2px 0;
|
||
cursor: pointer;
|
||
align-self: flex-start;
|
||
}
|
||
.dai-link:hover {
|
||
color: var(--dai-text);
|
||
text-decoration: underline;
|
||
}
|
||
.dai-link:focus-visible {
|
||
outline: 2px solid var(--dai-accent);
|
||
outline-offset: 2px;
|
||
border-radius: 5px;
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Conversation
|
||
-------------------------------------------------------------------------- */
|
||
/* --------------------------------------------------------------------------
|
||
Scroll region
|
||
-------------------------------------------------------------------------- */
|
||
.dai-scroll {
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
/* Never scroll sideways. A single wide child (the create form's name row)
|
||
used to push the whole message area horizontally. */
|
||
overflow-x: hidden;
|
||
overscroll-behavior: contain;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--dai-border-strong) transparent;
|
||
}
|
||
|
||
.dai-scroll::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
.dai-scroll::-webkit-scrollbar-thumb {
|
||
background: var(--dai-border-strong);
|
||
border-radius: 999px;
|
||
border: 2px solid transparent;
|
||
background-clip: content-box;
|
||
}
|
||
|
||
.dai-scroll::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.dai-scroll-wrap {
|
||
position: relative;
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.dai-root .dai-thread {
|
||
padding: 16px 14px 8px;
|
||
}
|
||
|
||
/* Only the newest turn animates in. Applying this to every .dai-msg meant
|
||
opening a panel with a restored thread started 50 simultaneous opacity +
|
||
transform animations, which is a visible hitch before the first scroll. The
|
||
base state IS the animation's end state, so a turn that stops being last
|
||
simply keeps its finished appearance. */
|
||
.dai-root .dai-thread > .dai-msg:last-child {
|
||
animation: dai-enter 220ms var(--dai-ease) both;
|
||
}
|
||
|
||
@keyframes dai-enter {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(4px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
}
|
||
|
||
.dai-root .dai-msg-user {
|
||
max-width: 82%;
|
||
margin-left: auto;
|
||
padding: 8px 12px;
|
||
border-radius: 5px;
|
||
border-bottom-right-radius: 5px;
|
||
font-size: 13.5px;
|
||
line-height: 1.45;
|
||
color: var(--dai-accent-contrast);
|
||
background: var(--dai-accent);
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
/* Assistant — no bubble. Text sits on the panel surface. */
|
||
.dai-root .dai-msg-ai {
|
||
font-size: 13.5px;
|
||
line-height: 1.5;
|
||
color: var(--dai-text);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.dai-root .dai-msg-ai-detail {
|
||
font-size: 12.5px;
|
||
line-height: 1.55;
|
||
color: var(--dai-text-secondary);
|
||
white-space: pre-line;
|
||
}
|
||
.dai-root .dai-msg-name {
|
||
font-size: 11.5px;
|
||
font-weight: 600;
|
||
color: var(--dai-text-secondary);
|
||
}
|
||
.dai-root .dai-msg-footer {
|
||
font-size: 11px;
|
||
color: var(--dai-text-muted);
|
||
}
|
||
/* Copy button — only revealed on hover of the message row. */
|
||
.dai-root .dai-msg-actions {
|
||
opacity: 0;
|
||
transition: opacity 140ms ease;
|
||
}
|
||
.dai-root .dai-msg-row:hover .dai-msg-actions,
|
||
.dai-root .dai-msg-row:focus-within .dai-msg-actions {
|
||
opacity: 1;
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Structured metrics
|
||
-------------------------------------------------------------------------- */
|
||
.dai-root .dai-stat-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 6px;
|
||
width: 100%;
|
||
}
|
||
.dai-root .dai-stat {
|
||
padding: 9px 10px;
|
||
border: 1px solid var(--dai-border);
|
||
border-radius: 5px;
|
||
background: var(--dai-surface-alt);
|
||
}
|
||
.dai-root .dai-stat-value {
|
||
font-size: 19px;
|
||
font-weight: 650;
|
||
line-height: 1.1;
|
||
letter-spacing: -0.02em;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.dai-root .dai-stat-label {
|
||
font-size: 11px;
|
||
color: var(--dai-text-muted);
|
||
}
|
||
/* Headline metric — the "primary number is large" rule. */
|
||
.dai-root .dai-metric-value {
|
||
font-size: 28px;
|
||
font-weight: 680;
|
||
line-height: 1.05;
|
||
letter-spacing: -0.025em;
|
||
color: var(--dai-text);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.dai-root .dai-metric-label {
|
||
font-size: 12px;
|
||
color: var(--dai-text-secondary);
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Typing / loading
|
||
-------------------------------------------------------------------------- */
|
||
.dai-root .dai-typing {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
height: 18px;
|
||
}
|
||
.dai-root .dai-typing span {
|
||
width: 5px;
|
||
height: 5px;
|
||
border-radius: 999px;
|
||
background: var(--dai-text-muted);
|
||
animation: dai-bounce 1.3s ease-in-out infinite;
|
||
}
|
||
.dai-root .dai-typing span:nth-child(2) {
|
||
animation-delay: 0.16s;
|
||
}
|
||
.dai-root .dai-typing span:nth-child(3) {
|
||
animation-delay: 0.32s;
|
||
}
|
||
@keyframes dai-bounce {
|
||
0%,
|
||
70%,
|
||
100% {
|
||
opacity: 0.3;
|
||
transform: translateY(0);
|
||
}
|
||
35% {
|
||
opacity: 0.9;
|
||
transform: translateY(-3px);
|
||
}
|
||
}
|
||
.dai-root .dai-shimmer {
|
||
height: 9px;
|
||
border-radius: 999px;
|
||
background: linear-gradient(90deg, var(--dai-surface-hover) 25%, #e9eef5 37%, var(--dai-surface-hover) 63%);
|
||
background-size: 400% 100%;
|
||
animation: dai-shimmer 1.5s ease infinite;
|
||
}
|
||
@keyframes dai-shimmer {
|
||
from {
|
||
background-position: 100% 50%;
|
||
}
|
||
to {
|
||
background-position: 0 50%;
|
||
}
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
States (error / empty)
|
||
-------------------------------------------------------------------------- */
|
||
.dai-root .dai-state {
|
||
padding: 12px;
|
||
border: 1px solid var(--dai-border);
|
||
border-radius: 5px;
|
||
background: var(--dai-surface-alt);
|
||
}
|
||
.dai-root .dai-state-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--dai-text);
|
||
}
|
||
.dai-root .dai-state-body {
|
||
font-size: 12.5px;
|
||
line-height: 1.5;
|
||
color: var(--dai-text-secondary);
|
||
}
|
||
.dai-root .dai-state-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 5px;
|
||
flex: 0 0 auto;
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Jump-to-latest
|
||
-------------------------------------------------------------------------- */
|
||
.dai-jump {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 8px;
|
||
transform: translateX(-50%);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 5px 11px;
|
||
font: inherit;
|
||
font-size: 12px;
|
||
color: var(--dai-text);
|
||
background: var(--dai-surface);
|
||
border: 1px solid var(--dai-border-strong);
|
||
border-radius: 999px;
|
||
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.1);
|
||
cursor: pointer;
|
||
z-index: 2;
|
||
}
|
||
.dai-jump:hover {
|
||
background: var(--dai-surface-alt);
|
||
}
|
||
.dai-jump:focus-visible {
|
||
outline: 2px solid var(--dai-accent);
|
||
outline-offset: 2px;
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Composer
|
||
-------------------------------------------------------------------------- */
|
||
.dai-root .dai-composer-wrap {
|
||
flex: 0 0 auto;
|
||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
|
||
border-top: 1px solid var(--dai-border);
|
||
background: var(--dai-surface);
|
||
}
|
||
.dai-root .dai-composer {
|
||
border: 1px solid var(--dai-border-strong);
|
||
/* 14px, the same corner the suggestion chips use. At 5px the input was the
|
||
one sharp-cornered thing in a panel of rounded surfaces, and it sat
|
||
directly beneath the chips where the mismatch was most visible. */
|
||
border-radius: 14px;
|
||
background: var(--dai-surface);
|
||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||
padding: 10px 10px 8px 14px;
|
||
transition:
|
||
border-color 140ms ease,
|
||
box-shadow 140ms ease;
|
||
}
|
||
.dai-root .dai-composer[data-focused='true'] {
|
||
border-color: var(--dai-accent);
|
||
box-shadow: 0 0 0 3px var(--dai-accent-ring);
|
||
}
|
||
|
||
/* Microphone.
|
||
|
||
Astryx's smallest dictation button is 28px, two short of the send button it
|
||
sits beside — a visible step between two controls on the same row. Pinned to
|
||
match, with the glyph scaled through font-size because the icon inside is
|
||
sized in em units (the same reason the side nav icons needed font-size
|
||
rather than width). */
|
||
.dai-root .dai-mic {
|
||
width: 30px;
|
||
height: 30px;
|
||
min-width: 30px;
|
||
border-radius: 50%;
|
||
font-size: 16px;
|
||
}
|
||
.dai-root .dai-mic svg {
|
||
width: 1em;
|
||
height: 1em;
|
||
}
|
||
|
||
/* --------------------------------------------------------------------------
|
||
Send button.
|
||
|
||
This had NO styles at all — the class was referenced once, in a
|
||
reduced-motion reset, and nowhere else. It rendered as a bare 16px arrow
|
||
glyph on a transparent background with square corners: not a button, just an
|
||
icon sitting next to the microphone. Verified in the browser before fixing.
|
||
-------------------------------------------------------------------------- */
|
||
.dai-root .dai-send {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 auto;
|
||
width: 30px;
|
||
height: 30px;
|
||
padding: 0;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: var(--dai-accent);
|
||
color: var(--dai-accent-contrast);
|
||
cursor: pointer;
|
||
transition:
|
||
background-color 140ms ease,
|
||
opacity 140ms ease,
|
||
transform 140ms ease;
|
||
}
|
||
.dai-root .dai-send:hover:not(:disabled) {
|
||
opacity: 0.86;
|
||
}
|
||
.dai-root .dai-send:active:not(:disabled) {
|
||
transform: scale(0.94);
|
||
}
|
||
.dai-root .dai-send:focus-visible {
|
||
outline: 2px solid var(--dai-accent);
|
||
outline-offset: 2px;
|
||
}
|
||
/* Disabled is the resting state until something is typed, so it has to read as
|
||
"not yet", not as "broken" — a filled grey circle, same shape and weight. */
|
||
.dai-root .dai-send:disabled {
|
||
background: var(--dai-surface-hover);
|
||
color: var(--dai-text-muted);
|
||
cursor: default;
|
||
}
|
||
.dai-root .dai-composer textarea {
|
||
display: block;
|
||
width: 100%;
|
||
border: none;
|
||
outline: none;
|
||
resize: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
font: inherit;
|
||
font-size: 13.5px;
|
||
line-height: 1.45;
|
||
color: var(--dai-text);
|
||
background: transparent;
|
||
max-height: 108px; /* ~5 lines */
|
||
overflow-y: auto;
|
||
}
|
||
.dai-root .dai-composer textarea::placeholder {
|
||
color: var(--dai-text-muted);
|
||
}
|
||
.dai-root .dai-hint {
|
||
font-size: 10.5px;
|
||
line-height: 1.3;
|
||
color: var(--dai-text-muted);
|
||
padding-inline: 2px;
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Trigger (lives in the app TopNav)
|
||
-------------------------------------------------------------------------- */
|
||
.dai-trigger {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 40px;
|
||
height: 40px;
|
||
padding: 0;
|
||
border-radius: 5px;
|
||
border: 1px solid transparent;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
transition:
|
||
background-color 140ms ease,
|
||
border-color 140ms ease,
|
||
transform 140ms ease;
|
||
}
|
||
/* 30px box × 1.5 cancels the mark's transparent padding, so the visible D is
|
||
30px inside the 40px button. */
|
||
.dai-trigger-mark {
|
||
width: 30px;
|
||
height: 30px;
|
||
max-width: none;
|
||
object-fit: contain;
|
||
display: block;
|
||
transform: scale(1.5);
|
||
}
|
||
.dai-trigger:hover {
|
||
background: rgba(0, 0, 0, 0.05);
|
||
border-color: rgba(0, 0, 0, 0.1);
|
||
}
|
||
.dai-trigger:active {
|
||
transform: scale(0.94);
|
||
}
|
||
.dai-trigger:focus-visible {
|
||
outline: 2px solid var(--dai-accent);
|
||
outline-offset: 2px;
|
||
}
|
||
.dai-trigger[data-active='true'] {
|
||
background: rgba(0, 0, 0, 0.07);
|
||
border-color: rgba(0, 0, 0, 0.14);
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Responsive
|
||
-------------------------------------------------------------------------- */
|
||
@media (max-width: 1279px) {
|
||
.dai-root {
|
||
--dai-panel-width: 396px;
|
||
}
|
||
}
|
||
@media (max-width: 767px) {
|
||
.dai-root {
|
||
--dai-inset: 0px;
|
||
}
|
||
.dai-panel {
|
||
width: 100vw;
|
||
max-width: 100vw;
|
||
border-radius: 0;
|
||
border: none;
|
||
}
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Reduced motion — no slide, no pulse, no shimmer. Opacity only.
|
||
-------------------------------------------------------------------------- */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.dai-panel,
|
||
.dai-scrim {
|
||
transition: opacity 1ms linear;
|
||
}
|
||
.dai-panel {
|
||
transform: none;
|
||
}
|
||
.dai-msg,
|
||
.dai-live-dot,
|
||
.dai-typing span,
|
||
.dai-shimmer,
|
||
.dai-suggestion,
|
||
.dai-send,
|
||
.dai-trigger {
|
||
animation: none !important;
|
||
transition: none !important;
|
||
}
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Write-action confirm card
|
||
-------------------------------------------------------------------------- */
|
||
.dai-root .dai-action {
|
||
padding: 10px 11px;
|
||
border: 1px solid var(--dai-border-strong);
|
||
border-radius: 5px;
|
||
background: var(--dai-surface-alt);
|
||
}
|
||
.dai-root .dai-action-icon {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 auto;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 5px;
|
||
color: #4f46e5;
|
||
background: rgba(99, 102, 241, 0.1);
|
||
}
|
||
.dai-root .dai-action-summary {
|
||
font-size: 13px;
|
||
line-height: 1.35;
|
||
font-weight: 550;
|
||
color: var(--dai-text);
|
||
}
|
||
.dai-root .dai-action-note {
|
||
font-size: 12px;
|
||
color: var(--dai-text-secondary);
|
||
}
|
||
.dai-root .dai-ok {
|
||
color: #047857;
|
||
}
|
||
.dai-root .dai-err {
|
||
color: #b91c1c;
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Overflow containment
|
||
--------------------------------------------------------------------------
|
||
Flex items default to `min-width: auto`, which means they refuse to shrink
|
||
below their content's intrinsic width. Two side-by-side TextInputs in the
|
||
create-customer form therefore pushed the message column wider than the
|
||
panel and produced a horizontal scrollbar. Every link in the chain from the
|
||
scroll region down to the field has to opt out of that.
|
||
-------------------------------------------------------------------------- */
|
||
.dai-root .dai-thread,
|
||
.dai-root .dai-msg,
|
||
.dai-root .dai-msg-row,
|
||
.dai-root .dai-action,
|
||
.dai-root .dai-form {
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
}
|
||
/* Two fields per row that genuinely share the width. `flex-wrap` is the
|
||
belt-and-braces part: if a field ever can't compress far enough (a long
|
||
label, a narrower panel), the row drops to two lines instead of pushing the
|
||
panel sideways again. */
|
||
.dai-root .dai-form-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
.dai-root .dai-form-row > * {
|
||
flex: 1 1 140px;
|
||
min-width: 0;
|
||
}
|
||
/* Astryx's TextInput sizes itself to content unless told otherwise. */
|
||
.dai-root .dai-form input,
|
||
.dai-root .dai-form-row input {
|
||
width: 100%;
|
||
min-width: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
.dai-root .dai-field-err {
|
||
font-size: 11.5px;
|
||
line-height: 1.4;
|
||
color: #b91c1c;
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Source footer + full lists
|
||
-------------------------------------------------------------------------- */
|
||
|
||
/* Timestamp left, Sources toggle hard right, both on ONE line. It was a
|
||
wrapping baseline row, and the toggle — a shrinkable flex item with inline
|
||
content — gave up its chevron to a second line whenever the panel got
|
||
narrow, which read as a stray arrow floating under the text. */
|
||
.dai-root .dai-msg-foot {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: nowrap;
|
||
gap: 10px;
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
/* The timestamp is the only part allowed to give up space, and it ellipsises
|
||
rather than wrapping. */
|
||
.dai-root .dai-msg-foot .dai-msg-footer {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
/* The toggle is one atom: label and chevron cannot be separated. */
|
||
.dai-root .dai-msg-foot .dai-sources {
|
||
flex: 0 0 auto;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
align-self: center;
|
||
white-space: nowrap;
|
||
padding: 3px 7px;
|
||
margin-right: -7px;
|
||
border-radius: 5px;
|
||
color: var(--dai-text-muted);
|
||
transition: background 140ms ease, color 140ms ease;
|
||
}
|
||
.dai-root .dai-msg-foot .dai-sources:hover {
|
||
background: var(--dai-surface-hover);
|
||
color: var(--dai-text);
|
||
text-decoration: none;
|
||
}
|
||
.dai-root .dai-msg-foot .dai-sources svg {
|
||
flex: 0 0 auto;
|
||
transition: transform 160ms var(--dai-ease);
|
||
}
|
||
.dai-root .dai-msg-foot .dai-sources[aria-expanded='true'] svg {
|
||
transform: rotate(180deg);
|
||
}
|
||
/* A full, readable list instead of "…and 4 more". Scrolls past ~12 rows so a
|
||
long list can never push the panel's height around. */
|
||
.dai-root .dai-list {
|
||
width: 100%;
|
||
min-width: 0;
|
||
border: 1px solid var(--dai-border);
|
||
border-radius: 5px;
|
||
background: var(--dai-surface-alt);
|
||
overflow: hidden;
|
||
}
|
||
.dai-root .dai-list-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 7px 10px;
|
||
border-bottom: 1px solid var(--dai-border);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
color: var(--dai-text-muted);
|
||
}
|
||
.dai-root .dai-list-body {
|
||
max-height: 260px;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
overscroll-behavior: contain;
|
||
}
|
||
.dai-root .dai-list-item {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: baseline;
|
||
gap: 2px 8px;
|
||
padding: 6px 10px;
|
||
font-size: 12.5px;
|
||
line-height: 1.4;
|
||
color: var(--dai-text);
|
||
border-top: 1px solid var(--dai-divider, rgba(15, 23, 42, 0.05));
|
||
}
|
||
.dai-root .dai-list-item:first-child {
|
||
border-top: none;
|
||
}
|
||
.dai-root .dai-list-index {
|
||
flex: 0 0 auto;
|
||
min-width: 18px;
|
||
font-size: 11px;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--dai-text-muted);
|
||
}
|
||
/* The label must not be crushed to zero width by a long value — that is what
|
||
made "address" render one letter per line. It sizes to its content and the
|
||
value takes the remaining space, wrapping onto its own full-width line when
|
||
there isn't enough room for both. */
|
||
.dai-root .dai-list-label {
|
||
flex: 0 1 auto;
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.dai-root .dai-list-meta {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
text-align: right;
|
||
font-size: 11.5px;
|
||
color: var(--dai-text-secondary);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
/* A value that wrapped to its own line reads better left-aligned under its
|
||
label than pinned to the right edge. */
|
||
.dai-root .dai-list-item > .dai-list-meta:only-child,
|
||
.dai-root .dai-list-meta[data-wrapped='true'] {
|
||
text-align: left;
|
||
}
|
||
/* --------------------------------------------------------------------------
|
||
Documentation answers
|
||
--------------------------------------------------------------------------
|
||
Retrieved passages are shown VERBATIM with their source file. They are prose
|
||
rather than a figure, so they get a quieter, wider treatment than a metric —
|
||
and the source line matters as much as the text, because the operator needs
|
||
to know this came from a document, not from live data.
|
||
-------------------------------------------------------------------------- */
|
||
.dai-root .dai-doc {
|
||
padding: 10px 11px;
|
||
border: 1px solid var(--dai-border);
|
||
border-left: 2px solid var(--dai-ai-from);
|
||
border-radius: 5px;
|
||
background: var(--dai-surface-alt);
|
||
font-size: 12.5px;
|
||
line-height: 1.55;
|
||
color: var(--dai-text);
|
||
white-space: pre-line;
|
||
overflow-wrap: anywhere;
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
}
|
||
.dai-root .dai-doc-source {
|
||
font-size: 11px;
|
||
color: var(--dai-text-muted);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|