Appearance

Appearance ​

The panel ships as one stylesheet that knows nothing about any host, plus one small adapter per host that translates it into that host's own tokens. Everything on this page follows from that split — which file to import, which custom properties to override, and what the two placements actually do.

Five entry points ​

importwhat it iswhen
@cloflin/docpilot/style.csscore + VitePress adapterthe default, and what …/theme already imports
@cloflin/docpilot/style/core.cssthe panel, on real valuesany other host, or your own mapping
@cloflin/docpilot/style/vitepress.cssthe VitePress mapping onlyyou replaced the core and kept the host
@cloflin/docpilot/style/docusaurus.cssthe Docusaurus mapping onlyloaded for you by the Docusaurus plugin
@cloflin/docpilot/web.cssthe core, from the prebuilt bundlea <script> tag, or the React adapter

All five are in @layer docpilot. An unlayered rule beats a layered one whatever its specificity, so any CSS your site already has wins over the panel's without !important and without counting selectors — .docpilot__send { border-radius: 4px } is enough. The core and an adapter share the one layer, so the order below still decides between them.

It points the other way too. A reset your page loads unlayered outranks the panel just as easily:

hostwhat overrules the panelwhat to do
VitePress 2nothing — its reset is in @layer __vitepress_base, and style.css names that layer before docpilot—
VitePress 1the unlayered base.css: button { padding: 0; background: transparent }, ul { list-style: none }, a { color: inherit }upgrade to VitePress 2 — there is no unlayered build to fall back on
DocusaurusInfima's bare h1–h6, ul, code, pre and a in the answer bodyturn on future.v4.useCssCascadeLayers, which puts Infima in a layer
Tailwind v3preflight's unlayered * { border: 0 solid } erases every hairlineTailwind v4 layers it; on v3, declare the border back

On a site whose own CSS is layered, the layer named first loses. Load the panel's stylesheet after yours, or name the order yourself before anything else — @layer base, docpilot; — and on VitePress 2 keep __vitepress_base ahead of docpilot in that list.

Three rules in the VitePress adapter stay outside the layer, because each exists to beat a declaration VitePress makes on its own component: the search box's flex-grow, which would otherwise push the navbar trigger 398px away from it, and the sidebar's transition in both directions of ui.layout: 'push'. The still variant of that transition stays out with them, because it has to cancel it.

An adapter is loaded after the core, always. The Docusaurus plugin lists them in that order itself; everywhere else it is the order of your two imports.

import { withDocPilot } from '@cloflin/docpilot/theme' pulls in the bundle. To supply the CSS yourself:

js
// docs/.vitepress/theme/index.js
import DefaultTheme from 'vitepress/theme'
import { withDocPilot } from '@cloflin/docpilot/theme-without-styles'
import '@cloflin/docpilot/style/core.css'
import './my-docpilot-tokens.css'

export default withDocPilot(DefaultTheme)

Order is the mechanism, not a convention. Inside @layer docpilot the core declares every token with a real value; the adapter re-declares the colour set on :root at the same specificity and wins by coming second. Load the adapter first and it loses. Your own tokens are outside the layer and win in any position. Load it alone and the panel has no styles at all — it declares no rules of its own, only a translation table.

Surfaces come in three levels ​

This is the one thing worth knowing before the table. Everything the panel paints sits on one of three levels, and the level is what decides which token it uses:

leveltokenwhat wears it
0--dp-surfacethe panel, and anything that is a surface — the composer, the code copy button, the floating button
1--dp-surface-2an object's own paint — the question bubble, a suggestion row, a pressed toggle's track, inline code — and a row's hover
2--dp-washa pointer on a control — an icon button, a pill, an inline text button — composited on top of whatever it sits on

A delete button inside a hovered history row therefore paints level 2 over level 1 and reads as a darker chip inside a lighter row. It separates by compositing, with no rule written for the case.

The tokens ​

Everything the panel paints goes through one of these. The second column is what the core declares with no host; the two after it are what each adapter re-declares it as.

tokencore (light / dark)on VitePresson Docusaurus
--dp-surface#ffffff / #1b1b1fvar(--vp-c-bg)var(--ifm-background-color)
--dp-surface-2rgba(101,117,133,.12) / .16var(--vp-c-default-soft)var(--ifm-color-emphasis-100)
--dp-washcolor-mix(in srgb, var(--dp-text) 12%, transparent)derived — not re-declaredderived
--dp-linergba(101,117,133,.24) / rgba(130,130,140,.32)var(--vp-c-divider)var(--ifm-color-emphasis-300)
--dp-lipcolor-mix(in srgb, var(--dp-text) 15%, transparent)derived — not re-declaredderived
--dp-text#1f2328 / #dfdfd6var(--vp-c-text-1)var(--ifm-font-color-base)
--dp-text-dim#5c6672 / #98989fvar(--vp-c-text-2)var(--ifm-color-emphasis-700)
--dp-on-text#ffffff / #1b1b1fvar(--vp-c-neutral-inverse, var(--vp-c-bg))var(--ifm-background-color)
--dp-focus#1f2328 / #dfdfd6var(--vp-c-brand-1)var(--ifm-color-primary)
--dp-accent-softrgba(100,108,255,.18)var(--brand-bg-active, var(--vp-c-brand-soft))the primary at 18%, via color-mix
--dp-alert#e34c1enot re-declarednot re-declared
--dp-chiprgba(101,117,133,.08) / .16var(--vp-c-bg-alt)var(--ifm-color-emphasis-100)
--dp-code-bgrgba(101,117,133,.08) / .16var(--vp-code-block-bg)var(--ifm-code-background)
--dp-shadowtwo-layer black alphavar(--vp-shadow-3)var(--ifm-global-shadow-lw)
--dp-scrimrgba(0,0,0,.6)var(--vp-backdrop-bg-color)the same literal — Infima has no token
--dp-fontinherit — the page's own facevar(--vp-font-family-base)var(--ifm-font-family-base)
--dp-font-monoa system monospace stackvar(--vp-font-family-mono)var(--ifm-font-family-monospace)
--dp-top0pxvar(--vp-nav-height)var(--ifm-navbar-height)

--dp-wash and --dp-lip are derived rather than mapped, and deliberately: substitution resolves it against whatever --dp-text ends up being, so it follows the host's theme — and your override of it — without a second declaration. Neither has a dark variant and neither may be given one; the suite requires every token that darkens to be re-declared unconditionally by an adapter, and no host token expresses "7% in light, 15% in dark" as one value.

--dp-lip is the second and last border colour in the package, and it is a different job from the hairline rather than a second one of it. The jump pill is glass over the thread — a translucent fill under backdrop-filter: blur(2px) — and a translucent thing with no light on its underside reads as a hole in the thread instead of an object over it. --dp-line still draws the pill's other three sides. Because it resolves against --dp-text, it is a near-white rim in dark and a grounded dark edge in light; a literal rgba(255,255,255,.15) would have been right in one scheme and invisible in the other.

--dp-alert is the one colour in the package that is not part of your theme, and the reason it is not mapped is the same reason it exists. It paints a single 10px dot on the trigger, and only when an answer settled while the panel was closed — see ui.background. "There is something waiting" is not a statement any brand palette has a value for, so an adapter that reached for the host's own warning colour would be answering a question the host never asked; a site that disagrees repoints the one token. It has no dark variant because it needs none: the shipped literal clears the 3:1 non-text floor against both surfaces, at 4.8:1 on the light one and 3.6:1 on the dark. It is never text, never a border, and the only place it appears outside that dot is forced-colors, where the system palette takes it over entirely.

Geometry is declared once and does not vary with appearance:

tokenvaluefor
--dp-widthclamp(360px, 30vw, 460px)the drawer and the popup, deliberately the same
--dp-bar-inline, --dp-bar-inline-open400px, 640pxthe bar under panel: 'bar' at rest, and in use — focused, holding a question, or under its window, which takes the same width
--dp-gutter20pxthe panel's inline padding
--dp-r-sm8pxicon buttons, the copy chip, the nav trigger, inline text buttons, inline code
--dp-r-md12pxrows and blocks: suggestion, source, pick, the code card
--dp-r-lg16pxthe popup, the feedback textarea
--dp-r-bubble22pxthe question
--dp-r-field28pxthe composer
--dp-r-pill999pxsend, pills, the floating button, the status dot
--dp-dur400msevery entrance; every exit takes half of it
--dp-dur-fast150msevery colour, background and opacity change
--dp-easea spring as linear() — stiffness 350, damping 35, no visible overshootpanels, the sheet, the pushed page, every press
--dp-ease-popa spring as linear() — stiffness 300, damping 25, a 3.8% overshootwhat pops: the popup, the bar and its window, a new question, a check glyph
--dp-ease-exitcubic-bezier(0.4, 0, 1, 1)every exit — a thing that leaves accelerates away
--dp-z29, 70 below 960pxabove VitePress's own sidebar on the sheet
--dp-fab-size, --dp-fab-size-coarse48px, 56pxthe floating button's block size
--dp-popup-inset20pxthe distance from the corner, shared by the button and the popup so they line up
--dp-popup-block640pxthe popup's height ceiling
--dp-fab-clear--dp-fab-size + 12px, or 0px with no floating button on the pagethe room the popup leaves below itself for the button it opens above

Radii do not animate. They used to be rest/hover/focus tiers that grew under the cursor; they are static now, because the reference this design follows has static corners and a shape that moves competes with the colour change already saying the same thing.

Override any of them after the stylesheet:

css
:root {
  --dp-width: 520px;
  --dp-popup-block: 560px;
}

The core's dark values come from prefers-color-scheme. An adapter has to re-declare the whole colour set unconditionally, not the tokens that differ: every host switches appearance by a class or an attribute and lets a reader pin the site against their OS, so a token left out would keep its OS-driven value and paint one dark element into an otherwise light panel. The suite checks this pairing, for every adapter.

ui.theme overrules both. 'light' and 'dark' put a class on <html> and the core re-declares the nine tokens that differ between its own two sets, one class deeper than the :root an adapter maps on — because an adapter wins by loading second, not by specificity, so a pin written at :root would lose on exactly the hosts with a toggle to overrule. --dp-accent-soft, --dp-shadow and --dp-scrim are deliberately left out: they have one value for both schemes, so under a pin they stay the host's. The default, 'auto', changes nothing on this page.

Writing one for a host of your own is the same shape: a :root block that re-declares those fifteen names in your tokens' terms, loaded after the core. It must introduce no --dp-* of its own — a token that exists only in an adapter is a token the core cannot render without it, which is the failure the split exists to prevent.

The panel wears your font ​

It ships none of its own. --dp-font is inherit, and the panel is mounted into <body>, so with nothing configured it is set in whatever face the page is — the same thing the navbar trigger and the call to action under each article have always done. On VitePress and Docusaurus the adapter maps the token to the host's own family, so a theme that changes its type changes the panel's with it.

--dp-font-mono is a real stack and not inherit: a page has no monospace face for the panel to borrow, so the code blocks, the reasoning trace and the prompt disclosure keep a system one until you name another.

Two ways to name one, and they are for different situations:

css
/* CSS — when the value depends on something only a stylesheet knows:
   a media query, a [data-theme], a container. */
:root {
  --dp-font: var(--my-font);
}
js
// settings — when it is one value for the whole site. A family list, or the
// name of the custom property you already keep it in.
docPilot: {
  ui: { font: '--brand-font', fontMono: 'JetBrains Mono, monospace' },
}

ui.font is written onto <html> as an inline custom property, which outranks an adapter — so it reaches the panel on VitePress and Docusaurus too, where the host's own family would otherwise win. A :root rule of your own outranks the adapter as well, from any unlayered stylesheet, because the adapter is in @layer docpilot.

Nothing nested may ask for --dp-font. inherit is resolved against the element that uses it, so var(--dp-font) written inside a monospaced block returns the monospace. The one place that used to do it — the heading between the prompt disclosure's blocks — is why the prompt panel now carries the face and marks its monospaced blocks instead of the other way round.

Three shapes, any edge ​

js
docPilot: {
  ui: { trigger: 'fab', panel: 'auto', side: 'end', fabLabel: true, fabIcon: true, theme: 'auto' },
}

See ui for the values. What each produces:

Drawer — full height, docked to an edge — the trailing one unless ui.side moves it — --dp-width wide, starting below --dp-top, with a hairline on the face that looks at the page. With ui.layout: 'push' the page moves away from that edge while it is open, and back as it leaves.

Popup — --dp-width wide and at most --dp-popup-block tall, --dp-popup-inset from its edge, sitting --dp-fab-clear above it — 12px above the floating button, or flush against the inset when ui.trigger does not include one — at --dp-r-lg. It carries a hairline and --dp-shadow: a shadow alone is invisible on a dark host and erased outright by forced-colors, which is the one mode where a floating surface most needs an edge.

Bar — panel: 'bar', and what 'auto' draws with the floating placement, so the shipped shape. The composer's field, at the bottom centre of every page: --dp-bar-inline wide at rest, --dp-bar-inline-open once it is focused, holds a question or sits under its window, at --dp-r-field with a hairline and --dp-shadow. The window rises above it — the popup's recipe, centred, as tall as what it holds and never taller than --dp-popup-block — at the first character typed. It replaces the floating button, and ui.side does not apply: it is centred.

The floating button stays visible while the popup is open and closes it again — the convention every chat widget has already taught, and what makes returning focus to it correct; its sparkle turns into a chevron for as long as the popup is up. It hides itself when it would be underneath: always beneath a drawer, and below 960px beneath any shape.

With a label it becomes a pill and grows on the inline axis only — --dp-fab-size is an input to the popup's own geometry, which sits a fixed distance above the button, so a taller control would move the panel and a wider one cannot. --dp-fab-size-coarse still applies as the block size on a pointer-less device, so the target floor is met either way.

What the panel's chrome does ​

The header's divider is absent at rest. At the top of the thread there is no rule under the title; it fades in the moment the conversation is scrolled, and out again at the top. The native form of this — @container scroll-state(...) — is Chromium-only, so it is a class from a passive scroll listener, which is the fallback that feature's own guidance prescribes. The border is declared transparent and switched by colour, never added and removed, so crossing the top of the scroller cannot move the thread by a pixel.

Past conversations open above the thread, under the header button that opens them, with one hairline against the thread and a list that scrolls inside min(240px, 32dvh). The scope picker and the prompt disclosure stay at the bottom, under the footnote row they are opened from. At most one of the three is open.

Selecting inside an answer offers to quote it. A one-button popover appears above the selection; pressing it puts the passage in the composer as a chip the reader can withdraw, and the question is then asked about it. The popover floats, so it takes a hairline and --dp-shadow like the popup, the floating button, the bar and the bar's window — the five things in the package that do. It uses the platform's top layer where there is one, which is what keeps the popup shape from clipping it, and a plain fixed box where there is not. The chip is --dp-surface-2 at --dp-r-md, one line, 13px, dimmed — a quotation reads as quieter text here, never as a rule down its side.

The hairline is the structure. One device, one value — 1px solid var(--dp-line) — on the drawer's edge, the popup and the bar's window, the bar, the selection popover, the header (transparent until the thread is scrolled), the conversation dock, the scope picker, the composer, the feedback textarea, the code card and the floating button. outline is the focus ring and nothing else: 2px solid var(--dp-focus) at 2px offset, on every control. The one exception is the jump pill's bottom edge, which takes --dp-lip because it is glass lying on the thread and needs an underside rather than a boundary.

The window is glass. From 960px up the drawer, the popup and the bar's window are your --dp-surface at 70% under backdrop-filter: blur(24px) saturate(180%): the page behind arrives as colour and shape, never as legible text, and the answer keeps its full contrast. The two floating shapes add a one-pixel highlight on their top edge, light falling on the glass, which disappears on a light surface as a highlight would. Below 960px the sheet stays opaque. Nothing inside is a second glass: the composer is an opaque field on it, and the jump pill's blur stays at 2px.

A row has one hover. In the header, the action rows under a question and an answer, the scope picker's presets, the suggestions and every list of sources — past conversations included — the hover is a single fill that travels from the item you left to the one you reached, on the settle spring: across a row of buttons, down a list, taking each item's width and height on the way. It looks like the hover it replaces — the wash on a control, --dp-surface-2 on a source row — appears in place when the pointer arrives from outside, and fades in place when it leaves. Keyboard focus keeps each item's own fill and ring, and a linked source keeps its accent.

Buttons are pills, not links. No border, a --dp-wash on hover and focus, scale(0.96) on press, and the inverted --dp-text / --dp-on-text pair for a toggle that is on. The three controls inside the footnote sentence, and the call-to-action under each article, keep their underline — a pill in running text breaks the line box — and take the same wash at --dp-r-sm.

The footnote closes on one word. All docs · AI-generated. Check the linked pages. · DocPilot — the scope button, the disclaimer once a turn exists, and a link to the project so a reader can find out what answered them. The link is the one segment that is always there; it wears --dp-text like the two controls beside it rather than your accent, because a coloured word would be the only one in the panel. ui.credit: false removes it.

The composer does not answer a hover. It is a --dp-r-field stadium with a hairline and the page's own surface, and only focus changes it. A tinted field and a ring would be two devices doing one job.

Icons come from one sprite. DocPilotIcons teleports a 0×0 <svg> of <symbol> definitions into <body>; every glyph in the panel is a <use> into it, painted by currentColor inherited from the referencing element. It is mounted for you by withDocPilot / docPilotSlots(). If you compose the components by hand, mount it alongside the panel:

js
import { DocPilot, DocPilotIcons, DocPilotTrigger } from '@cloflin/docpilot/theme'

The navbar trigger and the floating button deliberately do not depend on it — their single glyph is inline, so composing only the trigger still works.

Type is four sizes. 24px for the empty state's greeting and nothing else; 16px for the answer, the question and both composers; 14px for titles, rows and labels; 13px for meta and mono. Weights are 400, 500 and 600. Both composers are 16px at every width, which is also what stops iOS zooming the viewport when one is focused.

Below 960px the three shapes are identical: a full-screen sheet with a scrim, role="dialog" and safe-area padding. The bar stays on the page as a button that looks like its field and opens that sheet.

Motion is two springs and an exit. Everything that arrives moves on --dp-ease — a spring with no visible overshoot, for the drawer, the sheet, the pushed page — or --dp-ease-pop, a spring with a 3.8% overshoot, for what pops: the popup out of its button's corner, the bar's window out of the bar, a question you just asked, the check that says something was copied. Both play over --dp-dur. Everything that leaves does so in half that time on --dp-ease-exit, an ease-in, and the panel stays in the document until its exit has played. Empty-state suggestions rise in a 40ms stagger; an answer un-blurs into place on its first paint; a conversation restored from history simply is there. All of it is CSS — there is no animation library in the package.

What degrades, and how ​

Four rules use :has() — hiding the floating button under an open panel, shrinking the host's search box only when the navbar trigger is actually there, dropping the room the popup reserves below itself when there is no floating button to reserve it for, and letting the hover in a row take the press of the item under it. A browser without :has() applies none of them: the button stays visible behind a drawer that covers it, the trigger sits at the far edge of the navbar instead of beside search, a popup opened from the navbar floats one button-height above the corner, and the hover holds its size while the item it marks is pressed. All four are cosmetic, and all four miss in the safe direction — nothing is hidden and nothing stops working.

backdrop-filter is gated: a browser without it gets the opaque surface, because 70% without a blur is a window you read the page through. The glass also goes opaque under prefers-reduced-transparency: reduce, prefers-contrast: more and forced-colors: active — the jump pill's with it. Safari does not report reduced transparency yet; a reader there who also raised contrast is covered.

Motion is on by default, whatever the reader's OS says — ui.motion. 'auto' defers to prefers-reduced-motion and 'reduce' always applies the still variant: every entrance and every exit becomes a fade, the stagger goes, the bar keeps its two widths without travelling between them, the hover in a row or a list appears in place instead of travelling, the two repeating animations stop and every press scale is cancelled, restoring the status label's own colour rather than freezing it mid-gradient. On a VitePress default-theme site the theme's own base.css removes all motion with !important for a reader whose OS asks, whatever ui.motion says.

forced-colors needs less than it used to. That mode replaces every background with a system colour, but it forces a border's colour too — so the surfaces that wear a hairline survive it untouched, and only the fill-only ones (the question bubble, the quote chip, the send button, the prompt editor, the copy chip, the suggestion rows) take a CanvasText edge. The status label is repainted as well, since its gradient is clipped to text and would otherwise render as an empty line.

Syntax highlighting ​

The highlighter is pluggable — Shiki, Prism, highlight.js, or your own — and Syntax highlighting is the whole of that API. What belongs on this page is the part that is about colour.

Shiki writes both themes onto every token as custom properties and applies neither, so a stylesheet has to pick one. The core picks off prefers-color-scheme; each adapter states both branches by the host's own dark signal — html.dark and html:not(.dark) on VitePress, html[data-theme='dark'] and its negation on Docusaurus. The negative branch is not decoration: without it, a dark-OS reader on a site pinned to light gets the panel's code painted for a dark background. A site using ui.theme gets a third pair on the same terms, stated after the host's in each adapter — html.dark and html.docpilot-dark are the same specificity, so order is the only thing that separates them. This is the one thing in the package a token cannot cover, because the var() has to sit on a rule targeting the spans, which is the only place those variables exist.

Prism and highlight.js write classes, and the theme that colours them is the host's — the same stylesheet that already colours the host's own code blocks, and switched by whatever already switches those. The panel adds nothing and undoes exactly one thing: the background. Every highlight.js theme paints .hljs, and Prism's default theme gives .token.operator a translucent white that reads as a smear on a dark panel. The code card is one surface — --dp-code-bg — so both are neutralised inside it, and colour is left alone.