icon buttons
ghost · sm / md / lg
sm 28px
md 34px
lg 40px
filled surface
<!-- icon-only ghost md -->
<button class="btn btn-ghost btn-icon btn-md"
        data-tip="copy"
        aria-label="copy">
  <svg class="icon" viewBox="0 0 24 24">
    <rect x="9" y="9" width="13" height="13" rx="2"/>
    <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
  </svg>
</button>

<!-- size swap: .btn-sm | .btn-md | .btn-lg -->
<!-- variant swap: .btn-ghost | .btn-filled -->

<!-- additional icons (CHATer action bar palette) -->
<!-- retry:      <polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-4"/> -->
<!-- regenerate: <polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/> -->
<!-- thumbs-up:  <path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14z"/><path d="M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/> -->
<!-- thumbs-dn:  <path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10z"/><path d="M17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/> -->
<!-- code:       <polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/> -->
<!-- transcript: <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/> -->
<!-- import:     <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="12 18 12 12"/><polyline points="9 15 12 12 15 15"/> -->
<!-- close:      <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> -->

/* required CSS */
.btn-icon.btn-sm {  width: 28px; padding: 0; }
.btn-icon.btn-md {  width: 34px; padding: 0; }
.btn-icon.btn-lg {  width: 40px; padding: 0; }
.icon {  width: 14px; stroke: currentColor; fill: none; }
size tokens
classheightwidth (icon)icon size
.btn-sm28px28px14px
.btn-md34px34px14px
.btn-lg40px40px16px
tooltip -- pure CSS, zero JS

data-tip="label" on any .btn renders via ::after. Reads the attribute at paint time. No DOM nodes, no JS, no dependency. Position defaults to above; add data-tip-pos="below" to flip.

touch caveat -- hover never fires on mobile. Icon-only buttons on touch must have visible text or rely on context. Don't hide critical labels behind tooltips on mobile paths.

SVG icon spec

All icons: viewBox="0 0 24 24" · stroke-width: 1.6 · stroke-linecap: round · stroke: currentColor · fill: none. Color inherits from button state -- no separate icon theming. Source-compatible with Feather, Lucide, Heroicons Outline.

action bar icon palette
data-tipused in
retryuser bubble action bar
regenerateassistant bubble action bar
thumbs upassistant bubble action bar (.btn-toggled)
thumbs downassistant bubble action bar (.btn-toggled)
codeview switcher (json view)
transcriptview switcher (transcript view)
importfile import trigger
closedialogs, panels
accessibility

required Icon-only buttons have no visible label. Always add aria-label="action" or a .sr-only span. The CSS tooltip is not exposed to screen readers -- it's decoration only.

:focus-visible ring: 2px --accent at 2px offset. Keyboard nav works out of the box. Don't suppress focus outlines.

icon + label
<!-- ghost with mutable label -->
<button class="btn btn-ghost btn-md"
        onclick="flashLabelSuccess(this,'copy','copied!')">
  <svg class="icon" viewBox="0 0 24 24">...</svg>
  <span class="btn-label">copy</span>
</button>

<!-- accent primary -->
<button class="btn btn-accent btn-md">
  <svg class="icon">...</svg>
  save
</button>

<!-- danger -->
<button class="btn btn-danger btn-md">
  <svg class="icon">...</svg>
  delete
</button>
variant decision guide
variantuse whenavoid when
.btn-ghostsecondary / toolbar / non-destructiveprimary CTA -- too subtle
.btn-filledslightly elevated, dense UIsisolated CTAs -- use accent
.btn-accentone primary action per contextmultiple per row -- creates noise
.btn-dangerdelete, clear, resetanything reversible without confirmation
mutable label pattern

Wrap text in <span class="btn-label"> when it changes on action. Target btn.querySelector('.btn-label') -- never rebuild the element.

function flashLabelSuccess(btn, from, to) {
  const l = btn.querySelector('.btn-label');
  if (l) l.textContent = to;
  btn.classList.add('success');
  setTimeout(() => {
    btn.classList.remove('success');
    if (l) l.textContent = from;
  }, 1400);
}
icon alignment

Gap is 0.4rem on the flex container. Icon has flex-shrink: 0 so it never collapses on long labels. Never add margin directly to the SVG -- gap owns spacing.

copy feedback states

click to trigger

<!-- dual-icon swap pattern -->
<button class="btn btn-ghost btn-icon btn-md"
        onclick="flashIconSuccess(this)">
  <svg class="icon copy-icon" viewBox="0 0 24 24">
    <!-- copy paths -->
  </svg>
  <svg class="icon check-icon"
       style="display:none" viewBox="0 0 24 24">
    <polyline points="20 6 9 17 4 12"/>
  </svg>
</button>

function flashIconSuccess(btn) {
  btn.classList.add('success');
  btn.querySelector('.copy-icon').style.display = 'none';
  btn.querySelector('.check-icon').style.display = 'block';
  setTimeout(() => {
    btn.classList.remove('success');
    btn.querySelector('.copy-icon').style.display = 'block';
    btn.querySelector('.check-icon').style.display = 'none';
  }, 1400);
}
clipboard API

Wire the real copy before triggering the flash. Always handle the rejection -- clipboard access can be denied.

async function copyText(text, btn) {
  try {
    await navigator.clipboard.writeText(text);
    flashIconSuccess(btn);
  } catch {
    // execCommand fallback (deprecated, broad support)
    const el = document.createElement('textarea');
    el.value = text;
    document.body.appendChild(el);
    el.select();
    document.execCommand('copy');
    document.body.removeChild(el);
    flashIconSuccess(btn);
  }
}
feedback timing
durationfeeluse for
800mssnappy, forgettablefrequent repeated actions
1400msconfident, readabledefault -- copy, save confirm
2500msemphaticdestructive undo window
the .success class

Uses !important on border, color, bg so it works regardless of base variant. Add/remove via classList only -- never inline styles, or the removal won't clean up correctly.

.btn.success {
  border-color: var(--success) !important;
  color:        var(--success) !important;
  background:   rgba(74,222,128,.07) !important;
}
toolbar + overflow
standard group
with overflow
<div class="toolbar">
  <button class="btn btn-ghost btn-icon btn-md" data-tip="copy">...</button>
  <button class="btn btn-ghost btn-icon btn-md" data-tip="download">...</button>

  <div class="toolbar-overflow">
    <button class="btn btn-ghost btn-icon btn-md"
            onclick="toggleOverflow('menu-id', this)">
      <!-- dots icon -->
    </button>
    <div class="overflow-menu" id="menu-id">
      <button class="overflow-item">upload</button>
      <button class="overflow-item">save as</button>
      <button class="overflow-item danger">delete</button>
    </div>
  </div>
</div>

/* border model */
.toolbar .btn { border: none; border-right: 1px solid var(--line); }
.toolbar .btn:first-child { border-radius: 5px 0 0 5px; }
.toolbar .btn:last-child  { border-radius: 0 5px 5px 0; border-right: none; }
border model

Buttons inside .toolbar strip all border/radius. The container owns the outer border. Only a right-side divider remains per button, removed on :last-child. Adding or removing buttons is safe -- caps are automatic via pseudo-selectors.

overflow dismiss logic

A single document-level click listener closes all open menus when the click lands outside any .toolbar-overflow or trigger. Lightweight for single-toolbar pages. For pages with many toolbars, use a WeakMap keyed on trigger elements instead of string ID matching.

overflow item order
positionitem typewhy
topneutral (upload, rename)easy reach, low risk
middlesecondary savesexpected, not urgent
bottomdestructivedistance = friction = safety
mobile collapse
text "menu" -- no icon
<!-- text trigger: no icon -->
<div style="position:relative;display:inline-block">
  <button class="btn btn-ghost btn-md"
          onclick="toggleOverflow('id', this)">
    <span>menu</span>
    <!-- caret optional -->
  </button>
  <div class="overflow-menu" id="id">
    <button class="overflow-item">copy</button>
    <button class="overflow-item danger">delete</button>
  </div>
</div>

/* collapse pattern */
@media (max-width: 480px) {
  .toolbar      { display: none; }
  .mobile-menu  { display: inline-block; }
}
collapse strategy options
approachhowbest for
CSS media querydisplay:none toolbar, show triggerstatic toolbars, known breakpoints
ResizeObserverJS watches container width, swaps classesiframe panes (DIFFer, CSVer) -- unknown container
Container queries@container + container-typefuture-proof, modern browsers only
touch tap target

Min comfortable target: 44x44px (Apple HIG) / 48x48px (Material). .btn-md at 34px falls short. Extend invisibly without changing layout:

.btn::before {
  content: '';
  position: absolute;
  inset: -6px;  /* extends hit area to ~46px */
}
no-icon rationale

Skipping the hamburger avoids icon recognition overhead. The word menu is self-documenting. In a dense monospace UI, text labels are already the visual language -- icons would break dialect. The subtle caret signals expandability without requiring icon literacy.

button states
default
disabled
loading
success
toggled click to toggle
<!-- disabled -->
<button class="btn btn-ghost btn-md" disabled>copy</button>

<!-- loading -->
<button class="btn btn-ghost btn-md" disabled>
  <svg class="icon"
       style="animation:spin 0.9s linear infinite"
       viewBox="0 0 24 24">
    <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
  </svg>
  copying...
</button>

<!-- success -->
<button class="btn btn-ghost btn-md success">
  <svg class="icon" viewBox="0 0 24 24">
    <polyline points="20 6 9 17 4 12"/>
  </svg>
  copied!
</button>

<!-- toggled (persistent state, e.g. thumbs up/down) -->
<button class="btn btn-ghost btn-icon btn-md btn-toggled">
  <svg class="icon" viewBox="0 0 24 24">...</svg>
</button>

.btn-toggled {
  border-color: var(--accent) !important;
  color:        var(--accent) !important;
  background:   rgba(251,191,36,.1) !important;
}

@keyframes spin { to { transform: rotate(360deg); } }
state machine
stateCSS mechanisminteractivearia
default--yes--
hover:hoveryes--
disabled:disabled attrnoaria-disabled="true"
loadingdisabled + spinnernoaria-busy="true"
success.success classbriefly noaria-live="polite" on container
toggled.btn-toggled classyesaria-pressed="true"
loading -- use native disabled

The native disabled attribute blocks double-submits at the DOM level during async operations. No JS guard needed. Re-enable with btn.disabled = false -- don't toggle a class, the attribute is what matters.

async function handleSave(btn) {
  btn.disabled = true;
  // swap to spinner icon here
  await saveData();
  btn.disabled = false;
  flashSuccess(btn);
}
app icons
favicon favicon.svg · 32px
icon-192 icon-192.png · 192px
apple-touch-icon apple-touch-icon.png · 180px
icon-512 icon-512.png · 512px (maskable)
"icons": [
  { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
  { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
  { "src": "/icons/icon.svg",     "sizes": "any",     "type": "image/svg+xml" }
]
message action bar

hover a bubble to reveal actions

user bubble
How does the edge-to-client handoff work?
assistant bubble
Edge inference streams first while the local model downloads in the background…
<div class="bubble-wrap" data-idx="N">
  <div class="bubble bubble-ai">...</div>
  <div class="msg-action-bar">
    <button class="btn btn-ghost btn-icon btn-sm"
            data-action="copy" data-tip="copy" aria-label="copy">...</button>
    <button class="btn btn-ghost btn-icon btn-sm"
            data-action="thumbs-up" data-tip="good" aria-label="good">...</button>
    <button class="btn btn-ghost btn-icon btn-sm"
            data-action="thumbs-down" data-tip="not good" aria-label="not good">...</button>
    <button class="btn btn-ghost btn-icon btn-sm"
            data-action="regenerate" data-tip="regenerate" aria-label="regenerate">...</button>
    <button class="btn btn-ghost btn-icon btn-sm btn-danger"
            data-action="delete" data-tip="delete" aria-label="delete">...</button>
  </div>
  <div class="bubble-meta" id="meta-N"></div>
</div>
data contract

Each .bubble-wrap carries data-idx matching its position in history[] and metadata[]. A single delegated listener on #messages handles all action buttons via e.target.closest('[data-action]'). Never attach per-bubble listeners — this breaks on history restore.

streaming guard

During streaming, .bubble-wrap.streaming hides the action bar via display:none. Remove .streaming in onDone(). This prevents copy/edit/retry on partial responses.

toggled thumbs pattern

Thumbs use .btn-toggled (not .success) because the state persists until explicitly changed. Toggle is mutual: selecting "up" clears "down" and vice versa. Selecting the active thumb again clears it (toggle-off).

hover reveal

Action bar starts at opacity: 0 and fades in on .bubble-wrap:hover and :focus-within. Height is reserved even when invisible so layout doesn't shift on hover. Keyboard users (Tab into a bar button) also reveal it via :focus-within.