Tạp Chí Phố WallTCPW

Vue

Seven components, written to the rule.

Each file lives in design/examples/ and is a complete Nuxt 4 SFC: template, <script setup>, scoped style with tokens, mobile rules and a reduced-motion block. Copy one into app/components/ to start.

00

How to write it

Conventions

  • SFC shape. <template>, <script setup>, scoped <style>. Composition API. Components and composables are auto-imported from app/.
  • Class names. Namespace per component with a block and element: .sheet__head, .tcpw-btn--ghost. State classes are is-* or bare (.active) to match the app.
  • Tokens. Consume var(--*) from theme.css. Define a local custom property only as an ink pointer or a layout knob.
  • Radius. Every control writes border-radius: 0. See the rule.
  • Dark. Only when you introduced a literal: [data-theme="dark"] .x { --ink: #34d399 }.
  • Copy. $t() everywhere, sentence case in i18n/vi.json and en.json; CSS applies uppercase.
  • Overlays. Teleport to body, dialog semantics, focus trap, ESC, scroll lock through useBodyScrollLock.
  • Motion. Vue <Transition> classes with the house easing, and a reduced-motion block at the end of every style block.
  • AI features. Do not build a new analyzer UI. Add a registry entry in shared/config/ai-insights.ts and mount <AISidebar> with useAIInsightsStream().
Minimal analyzer mount
<template>
  <AISidebarToggle @click="open = true" />
  <AISidebar v-model:open="open" v-bind="aiSidebarProps" @generate="generateInsights" />
</template>

<script setup>
const open = ref(false)
const { sidebarProps: aiSidebarProps, generateInsights } = useAIInsightsStream('rrg')
</script>
01

examples/TcpwButton.vue

Button

Squared ink button with four inks and three sizes. Renders as a NuxtLink when given a route.

Usage
<TcpwButton variant="ghost" size="sm" @click="open = true">{{ $t('ai.generate') }}</TcpwButton>
TcpwButton.vue
<template>
  <component
    :is="href ? NuxtLink : 'button'"
    :to="href"
    :type="href ? undefined : type"
    class="tcpw-btn"
    :class="[`tcpw-btn--${variant}`, `tcpw-btn--${size}`, { 'is-block': block }]"
    :disabled="disabled || loading"
    :aria-busy="loading || undefined"
  >
    <span v-if="loading" class="tcpw-btn__spinner" aria-hidden="true" />
    <slot name="icon" />
    <slot />
  </component>
</template>

<script setup>
/**
 * Squared ink button. One shape, four inks.
 * ink     - solid ink, darkens on hover (note surfaces)
 * invert  - solid ink, inverts on hover (masthead, plan CTA)
 * ghost   - hairline, ink border on hover
 * outline - ink frame, fills on hover (footer)
 */
const NuxtLink = resolveComponent('NuxtLink')

defineProps({
  variant: { type: String, default: 'ink' },
  size: { type: String, default: 'md' },   // md 44px · sm 38px · xs 30px
  type: { type: String, default: 'button' },
  href: { type: [String, Object], default: null },
  block: Boolean,
  disabled: Boolean,
  loading: Boolean
})
</script>

<style scoped>
.tcpw-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  height: 44px;
  padding: 0 1.5rem;
  border: 1px solid var(--text-primary);
  border-radius: 0;
  background: var(--text-primary);
  color: var(--bg-color);
  font-family: var(--font-sans);
  font-size: 0.76rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}

.tcpw-btn :deep(svg) { width: 14px; height: 14px; flex-shrink: 0; }

.tcpw-btn:hover:not(:disabled) { background: var(--primary-hover); border-color: var(--primary-hover); }
.tcpw-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.tcpw-btn:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; }

.tcpw-btn--invert:hover:not(:disabled) { background: transparent; color: var(--text-primary); border-color: var(--text-primary); }

.tcpw-btn--ghost { background: transparent; color: var(--text-primary); border-color: var(--border-color); }
.tcpw-btn--ghost:hover:not(:disabled) { background: transparent; border-color: var(--text-primary); }

.tcpw-btn--outline { background: transparent; color: var(--text-primary); }
.tcpw-btn--outline:hover:not(:disabled) { background: var(--text-primary); color: var(--bg-color); }

.tcpw-btn--sm { height: 38px; padding: 0 1.1rem; font-size: 0.72rem; letter-spacing: 0.1em; }
.tcpw-btn--xs { height: 30px; padding: 0 12px; font-size: 0.66rem; letter-spacing: 0.1em; }
.is-block { width: 100%; }

.tcpw-btn__spinner {
  width: 14px;
  height: 14px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  animation: tcpw-spin 0.6s linear infinite;
}

@keyframes tcpw-spin { to { transform: rotate(360deg); } }

@media (max-width: 768px) {
  .tcpw-btn--md { width: 100%; }
}

@media (prefers-reduced-motion: reduce) {
  .tcpw-btn { transition: none; }
  .tcpw-btn__spinner { animation: none; }
}
</style>
02

examples/TcpwSectionHead.vue

Section head

Eyebrow, serif title, optional see-all link, hairline. Opens every block on a page.

Usage
<TcpwSectionHead :eyebrow="$t('home.markets')" :title="$t('home.earningsThisWeek')" to="/earnings-calendar" />
TcpwSectionHead.vue
<template>
  <header class="section-head">
    <div class="section-head__text">
      <p class="section-head__eyebrow">
        <span v-if="mark" class="section-head__mark" aria-hidden="true">◆</span>
        {{ eyebrow }}
      </p>
      <h2 class="section-head__title">{{ title }}</h2>
    </div>
    <div v-if="$slots.aside || to" class="section-head__aside">
      <slot name="aside">
        <NuxtLink :to="to" class="section-head__link">{{ $t('common.seeAll') }}</NuxtLink>
      </slot>
    </div>
  </header>
</template>

<script setup>
/**
 * Opens every block on a page: eyebrow, serif title, optional "see all",
 * then a hairline. Copy comes from i18n; the eyebrow is uppercase via CSS,
 * so keep source strings in sentence case.
 */
defineProps({
  eyebrow: { type: String, required: true },
  title: { type: String, required: true },
  to: { type: [String, Object], default: null },
  mark: Boolean
})
</script>

<style scoped>
.section-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid var(--border-color);
  margin-bottom: 24px;
}

.section-head__eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-tertiary);
}

.section-head__mark { color: var(--text-primary); }

.section-head__title {
  margin-top: 6px;
  font-family: var(--font-serif);
  font-size: 1.35rem;
  font-weight: 600;
  line-height: 1.25;
  letter-spacing: -0.01em;
  text-wrap: balance;
}

.section-head__link {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: var(--text-tertiary);
  font-size: 0.66rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  transition: color 0.2s ease;
}

.section-head__link::after { content: '→'; font-size: 0.85rem; transition: transform 0.25s ease; }
.section-head__link:hover { color: var(--accent-color); }
.section-head__link:hover::after { transform: translateX(3px); }
.section-head__link:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; }

@media (max-width: 480px) {
  .section-head__title { font-size: 1.2rem; }
}

@media (prefers-reduced-motion: reduce) {
  .section-head__link, .section-head__link::after { transition: none; }
}
</style>
03

examples/TcpwNotice.vue

Notice

Neutral frame; the kind repoints one --notice-ink custom property.

Usage
<TcpwNotice kind="limit" :title="$t('ai.errors.limitTitle')" :message="$t('ai.errors.limit')">
  <template #actions><TcpwButton size="sm" href="/pricing-plans">{{ $t('ai.actions.viewPlans') }}</TcpwButton></template>
</TcpwNotice>
TcpwNotice.vue
<template>
  <div class="notice" :class="`kind-${kind}`" :role="isAlert ? 'alert' : 'status'">
    <p class="notice__eyebrow">
      <span class="notice__mark" aria-hidden="true">◆</span>
      {{ $t('ai.eyebrow') }}
    </p>
    <h3 class="notice__title">{{ title }}</h3>
    <p class="notice__body">{{ message }}</p>
    <p v-if="meta" class="notice__meta">{{ meta }}</p>
    <div class="notice__actions">
      <slot name="actions" />
    </div>
  </div>
</template>

<script setup>
/**
 * One neutral frame. `kind` repoints a single --notice-ink custom property
 * that colours the 3px left bar and the diamond. Mirrors app/components/ai/AINotice.vue.
 * kinds: error · network · limit · tier · auth
 */
const props = defineProps({
  kind: { type: String, default: 'error' },
  title: { type: String, required: true },
  message: { type: String, required: true },
  meta: { type: String, default: '' }
})

const isAlert = computed(() => props.kind === 'error' || props.kind === 'network')
</script>

<style scoped>
.notice {
  --notice-ink: var(--text-primary);
  position: relative;
  max-width: 560px;
  margin: 0 auto;
  padding: 1.25rem 1.25rem 1.25rem 1.5rem;
  border: 1px solid var(--border-color);
  background: var(--card-bg);
  color: var(--text-primary);
  font-family: var(--font-sans);
}

/* bleed the bar 1px past the frame on three sides so it caps the border */
.notice::before {
  content: '';
  position: absolute;
  left: -1px;
  top: -1px;
  bottom: -1px;
  width: 3px;
  background: var(--notice-ink);
}

.kind-error, .kind-network { --notice-ink: var(--negative-color); }
.kind-limit { --notice-ink: var(--warning-color); }

.notice__eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-tertiary);
}

.notice__mark { color: var(--notice-ink); }

.notice__title {
  margin: 0.5rem 0 0.4rem;
  font-family: var(--font-serif);
  font-size: 1.3rem;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: -0.01em;
  text-wrap: balance;
}

.notice__body { font-size: 0.92rem; line-height: 1.6; color: var(--text-secondary); }
.notice__meta { margin-top: 0.6rem; font-size: 0.78rem; color: var(--text-tertiary); }

.notice__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin-top: 1.1rem;
}

.notice__actions:empty { display: none; }

@media (max-width: 480px) {
  .notice__actions { flex-direction: column; }
  .notice__actions :deep(.tcpw-btn) { width: 100%; }
}
</style>
04

examples/TcpwSegmented.vue

Segmented control

Binary control with a sliding ink thumb and arrow-key support.

Usage
<TcpwSegmented v-model="cycle" :options="[{ value: 'monthly', label: $t('pricing.monthly') }, { value: 'yearly', label: $t('pricing.yearly'), badge: '-20%' }]" />
TcpwSegmented.vue
<template>
  <div
    class="segmented"
    role="tablist"
    :data-active="modelValue === options[1].value ? 'b' : 'a'"
    @keydown.left.prevent="move(-1)"
    @keydown.right.prevent="move(1)"
  >
    <span class="segmented__thumb" aria-hidden="true" />
    <button
      v-for="opt in options"
      :key="opt.value"
      type="button"
      role="tab"
      :aria-selected="opt.value === modelValue"
      :tabindex="opt.value === modelValue ? 0 : -1"
      :class="{ active: opt.value === modelValue }"
      @click="$emit('update:modelValue', opt.value)"
    >
      {{ opt.label }}
      <span v-if="opt.badge" class="segmented__badge">{{ opt.badge }}</span>
    </button>
  </div>
</template>

<script setup>
/**
 * Binary segmented control with a sliding ink thumb.
 * Mirrors app/components/BillingCycleToggle.vue. Exactly two options.
 */
const props = defineProps({
  modelValue: { type: String, required: true },
  options: {
    type: Array,
    required: true,
    validator: (v) => v.length === 2
  }
})
const emit = defineEmits(['update:modelValue'])

function move(dir) {
  const i = props.options.findIndex((o) => o.value === props.modelValue)
  const next = props.options[(i + dir + 2) % 2]
  emit('update:modelValue', next.value)
}
</script>

<style scoped>
.segmented {
  position: relative;
  display: inline-grid;
  grid-template-columns: 1fr 1fr;
  padding: 3px;
  background: var(--card-bg);
  border: 1px solid var(--border-color);
  border-radius: 3px;
  isolation: isolate;
  max-width: 100%;
}

.segmented__thumb {
  position: absolute;
  top: 3px;
  bottom: 3px;
  left: 3px;
  width: calc(50% - 3px);
  background: var(--text-primary);
  border-radius: 2px;
  z-index: 0;
  transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}

.segmented[data-active="b"] .segmented__thumb { transform: translateX(100%); }

.segmented button {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  min-height: 38px;
  padding: 0 20px;
  border: none;
  border-radius: 2px;
  background: transparent;
  color: var(--text-secondary);
  font-family: var(--font-sans);
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  white-space: nowrap;
  cursor: pointer;
  transition: color 0.25s ease;
}

.segmented button:hover { color: var(--text-primary); }
.segmented button.active { color: var(--bg-color); }
.segmented button:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; }

.segmented__badge {
  padding: 2px 6px;
  border-radius: 2px;
  font-size: 0.56rem;
  letter-spacing: 0.06em;
  background: rgba(16, 185, 129, 0.12);
  color: #059669;
}

[data-theme="dark"] .segmented__badge { background: rgba(16, 185, 129, 0.18); color: #34d399; }

/* knock the badge out over the ink thumb */
.segmented button.active .segmented__badge {
  background: color-mix(in srgb, currentColor 18%, transparent);
  color: inherit;
}

@media (max-width: 768px) {
  .segmented { width: 100%; max-width: 360px; }
  .segmented button { flex-direction: column; min-height: 46px; }
}

@media (prefers-reduced-motion: reduce) {
  .segmented__thumb, .segmented button { transition: none; }
}
</style>
05

examples/TcpwStatFigure.vue

Statement figures

Borderless KPIs on a subgrid. Primary figure at 3rem, others at 1.75rem, tone from delta.

Usage
<TcpwStatFigure :figures="[{ label: $t('portfolio.totalValue'), value: fmt(total) }, { label: $t('portfolio.day'), value: fmt(day), delta: day }, { label: $t('portfolio.return'), value: pct(ret), delta: ret }]" />
TcpwStatFigure.vue
<template>
  <div class="hero" :style="{ '--cols': figures.length }">
    <div
      v-for="(f, i) in figures"
      :key="f.label"
      class="hero__figure"
    >
      <span class="hero__label">{{ f.label }}</span>
      <span
        class="hero__value"
        :class="[i === 0 ? 'is-primary' : 'is-secondary', tone(f.delta)]"
      >{{ f.value }}</span>
    </div>
  </div>
</template>

<script setup>
/**
 * Borderless statement figures on a subgrid: labels share a line,
 * figures share a baseline. Mirrors watchlist/WatchlistPortfolioSummary.vue.
 * figures: [{ label, value, delta?: number }]
 */
defineProps({
  figures: { type: Array, required: true }
})

function tone(delta) {
  if (delta == null || delta === 0) return ''
  return delta > 0 ? 'is-up' : 'is-down'
}
</script>

<style scoped>
.hero {
  display: grid;
  grid-template-columns: repeat(var(--cols, 3), auto);
  column-gap: 3rem;
  justify-content: start;
  padding: 0.25rem 0 1.5rem;
}

.hero__figure {
  display: grid;
  grid-template-rows: subgrid;
  grid-row: span 2;
  row-gap: 0.5rem;
  align-items: baseline;
  min-width: 0;
}

.hero__label {
  font-size: 0.625rem;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-tertiary);
  white-space: nowrap;
}

.hero__value {
  font-family: var(--font-serif);
  font-weight: 700;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums lining-nums;
  color: var(--text-primary);
  white-space: nowrap;
}

.is-primary { font-size: 3rem; line-height: 0.95; }
.is-secondary { font-size: 1.75rem; line-height: 1; letter-spacing: -0.01em; }

.is-up { color: var(--positive-color); }
.is-down { color: var(--negative-color); }

@media (max-width: 768px) {
  .hero { grid-template-columns: 1fr 1fr; }
  .hero__figure:first-child { grid-column: 1 / -1; }
  .is-primary { font-size: 2.5rem; }
}

@media (max-width: 380px) {
  .is-primary { font-size: 2.125rem; }
}
</style>
06

examples/TcpwDataTable.vue

Data table

Dense zero-radius table with sortable headers, zebra rows, and edge fades on overflow.

Usage
<TcpwDataTable :columns="cols" :rows="rows" />
TcpwDataTable.vue
<template>
  <div
    ref="scroller"
    class="table-scroll"
    :class="{ 'scroll-left': canLeft, 'scroll-right': canRight }"
    @scroll.passive="updateEdges"
  >
    <table class="table">
      <thead>
        <tr>
          <th
            v-for="col in columns"
            :key="col.key"
            :class="[{ r: col.numeric, sortable: col.sortable, sorted: sortKey === col.key }]"
            :aria-sort="ariaSort(col)"
            @click="col.sortable && toggleSort(col.key)"
          >
            {{ col.label }}<span v-if="sortKey === col.key" aria-hidden="true">{{ sortDir === 'asc' ? ' ↑' : ' ↓' }}</span>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="row in sorted" :key="row.id">
          <td
            v-for="col in columns"
            :key="col.key"
            :class="[{ r: col.numeric }, col.tone ? col.tone(row[col.key]) : '']"
          >
            {{ col.format ? col.format(row[col.key]) : row[col.key] }}
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script setup>
/**
 * Dense, flat, zero-radius data table. Mirrors app/pages/screener.vue.
 * columns: [{ key, label, numeric?, sortable?, format?, tone? }]
 * tone returns 'pos' | 'neg' | '' for a cell value.
 * Overflow scrolls inside the frame with edge fades; it never collapses to cards.
 */
const props = defineProps({
  columns: { type: Array, required: true },
  rows: { type: Array, required: true }
})

const sortKey = ref(null)
const sortDir = ref('desc')

const sorted = computed(() => {
  if (!sortKey.value) return props.rows
  const k = sortKey.value
  const dir = sortDir.value === 'asc' ? 1 : -1
  return [...props.rows].sort((a, b) => (a[k] > b[k] ? dir : a[k] < b[k] ? -dir : 0))
})

function toggleSort(key) {
  if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
  else { sortKey.value = key; sortDir.value = 'desc' }
}

function ariaSort(col) {
  if (!col.sortable) return undefined
  if (sortKey.value !== col.key) return 'none'
  return sortDir.value === 'asc' ? 'ascending' : 'descending'
}

const scroller = ref(null)
const canLeft = ref(false)
const canRight = ref(false)

function updateEdges() {
  const el = scroller.value
  if (!el) return
  canLeft.value = el.scrollLeft > 2
  canRight.value = el.scrollLeft + el.clientWidth < el.scrollWidth - 2
}

onMounted(updateEdges)
</script>

<style scoped>
.table-scroll {
  position: relative;
  overflow-x: auto;
  border: 1px solid var(--border-light);
  scrollbar-width: thin;
}

/* edge fades signal overflow on touch screens */
.table-scroll::before,
.table-scroll::after {
  content: '';
  position: sticky;
  top: 0;
  bottom: 0;
  width: 32px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s ease;
}

.table-scroll.scroll-left::before { opacity: 1; }
.table-scroll.scroll-right::after { opacity: 1; }

.table {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--font-sans);
  font-size: 12px;
}

.table thead { position: sticky; top: 0; background: var(--surface-2); }

.table th {
  padding: 11px 12px;
  text-align: left;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--text-tertiary);
  white-space: nowrap;
  border-bottom: 1px solid var(--border-color);
  user-select: none;
}

.table th.sortable { cursor: pointer; transition: color 0.15s, background 0.15s; }
.table th.sortable:hover { color: var(--text-primary); background: var(--hover-bg); }
.table th.sorted { color: var(--accent-color); }

.table td {
  padding: 9px 12px;
  color: var(--text-primary);
  white-space: nowrap;
  vertical-align: middle;
  border-bottom: 1px solid var(--border-lighter);
  font-variant-numeric: tabular-nums;
}

.table th.r, .table td.r { text-align: right; }

.table tbody tr:nth-child(odd) { background: var(--surface-2); }
.table tbody tr:hover { background: color-mix(in srgb, var(--accent-color) 6%, var(--card-bg)); }

.pos { color: var(--positive-color); font-weight: 600; }
.neg { color: var(--negative-color); font-weight: 600; }

@media (max-width: 480px) {
  .table th { padding: 9px 10px; }
  .table td { padding: 8px 10px; }
}
</style>
07

examples/TcpwSheet.vue

Right sheet

The research-note sheet: teleported, slides from the right, focus-trapped, scroll-locked.

Usage
<TcpwSheet v-model:open="open" :title="$t('rrg.aiInsights.title')" :subtitle="$t('rrg.aiInsights.subtitle')">
  <template #ledger><AIUsageBadge feature="rrg" /></template>
  <AIMarkdown :content="note" />
</TcpwSheet>
TcpwSheet.vue
<template>
  <Teleport to="body">
    <Transition name="scrim">
      <div v-if="open" class="sheet-scrim" @click="close" />
    </Transition>
    <Transition name="sheet">
      <aside
        v-if="open"
        ref="panel"
        class="sheet"
        role="dialog"
        aria-modal="true"
        :aria-labelledby="titleId"
        tabindex="-1"
        @keydown.tab="trap"
      >
        <header class="sheet__head">
          <div class="sheet__row">
            <p class="sheet__eyebrow">
              <span class="sheet__mark" aria-hidden="true">◆</span>
              {{ $t('ai.eyebrow') }}
              <span class="sheet__beta">{{ $t('ai.beta') }}</span>
            </p>
            <button ref="closeBtn" type="button" class="sheet__close" :aria-label="$t('ai.actions.close')" @click="close">
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 6l12 12M18 6L6 18" /></svg>
            </button>
          </div>
          <h2 :id="titleId" class="sheet__title">{{ title }}</h2>
          <p v-if="subtitle" class="sheet__sub">{{ subtitle }}</p>
        </header>

        <div class="sheet__ledger">
          <slot name="ledger" />
          <span class="sheet__live"><i class="sheet__dot" aria-hidden="true" /> {{ $t('ai.live') }}</span>
        </div>

        <div class="sheet__body">
          <slot />
          <p class="sheet__disclaimer">{{ $t('ai.disclaimer') }}</p>
        </div>
      </aside>
    </Transition>
  </Teleport>
</template>

<script setup>
/**
 * Right-hand research-note sheet. Mirrors app/components/AISidebar.vue:
 * fixed right, min(640px, 100vw), full dvh, slides in over 0.35s,
 * scroll lock, focus trap, ESC to close, focus restored on close.
 * The real app uses useBodyScrollLock() so a dialog opened over the sheet
 * does not unlock the page early; shown inline here for portability.
 */
const props = defineProps({
  open: Boolean,
  title: { type: String, required: true },
  subtitle: { type: String, default: '' }
})
const emit = defineEmits(['update:open'])

const titleId = useId()
const panel = ref(null)
const closeBtn = ref(null)
let restoreTo = null

function close() { emit('update:open', false) }

function focusables() {
  return Array.from(panel.value?.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])') || [])
    .filter((el) => !el.disabled)
}

function trap(e) {
  const els = focusables()
  if (!els.length) return
  const first = els[0], last = els[els.length - 1]
  if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
  else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
}

function onKey(e) { if (e.key === 'Escape' && props.open) close() }

watch(() => props.open, async (v) => {
  if (v) {
    restoreTo = document.activeElement
    document.body.style.overflow = 'hidden'
    await nextTick()
    closeBtn.value?.focus()
  } else {
    document.body.style.overflow = ''
    restoreTo?.focus?.()
  }
})

onMounted(() => window.addEventListener('keydown', onKey))
onBeforeUnmount(() => { window.removeEventListener('keydown', onKey); document.body.style.overflow = '' })
</script>

<style scoped>
.sheet-scrim {
  position: fixed;
  inset: 0;
  background: var(--overlay-background);
  z-index: calc(var(--z-ai-sheet) - 1);
}

.sheet {
  position: fixed;
  top: 0;
  right: 0;
  width: min(640px, 100vw);
  height: 100vh;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  background: var(--bg-color);
  border-left: 1px solid var(--border-color);
  box-shadow: -24px 0 48px -28px rgba(0, 0, 0, 0.45);
  z-index: var(--z-ai-sheet);
  outline: none;
  font-family: var(--font-sans);
}

.sheet__head { padding: 1.1rem 1.5rem; border-bottom: 1px solid var(--border-color); flex-shrink: 0; }
.sheet__row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }

.sheet__eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-tertiary);
}

.sheet__mark { color: var(--text-primary); }

.sheet__beta {
  padding: 2px 6px;
  border: 1px solid var(--border-color);
  font-family: var(--font-mono);
  font-size: 0.56rem;
  letter-spacing: 0.12em;
}

.sheet__close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  background: transparent;
  border: 1px solid var(--border-color);
  border-radius: 0;
  color: var(--text-secondary);
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}

.sheet__close svg { width: 16px; height: 16px; }
.sheet__close:hover { color: var(--text-primary); border-color: var(--text-primary); }
.sheet__close:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; }

.sheet__title {
  margin-top: 0.8rem;
  font-family: var(--font-serif);
  font-size: 1.55rem;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.01em;
  text-wrap: balance;
}

.sheet__sub { margin-top: 0.4rem; font-size: 0.88rem; line-height: 1.5; color: var(--text-secondary); }

.sheet__ledger {
  min-height: 42px;
  padding: 0.5rem 1.5rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  background: var(--surface-2);
  border-bottom: 1px solid var(--border-color);
}

.sheet__live {
  display: inline-flex;
  align-items: center;
  gap: 0.5em;
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-secondary);
}

.sheet__dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent-color);
  animation: sheet-breathe 1.8s ease-in-out infinite;
}

.sheet__body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 1.5rem;
  overscroll-behavior: contain;
}

.sheet__disclaimer {
  margin-top: 1.5rem;
  padding-top: 1rem;
  border-top: 1px solid var(--border-color);
  font-size: 0.74rem;
  line-height: 1.55;
  color: var(--text-tertiary);
}

.sheet-enter-active, .sheet-leave-active { transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1); }
.sheet-enter-from, .sheet-leave-to { transform: translateX(100%); }
.scrim-enter-active, .scrim-leave-active { transition: opacity 0.25s ease; }
.scrim-enter-from, .scrim-leave-to { opacity: 0; }

@keyframes sheet-breathe {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.4; transform: scale(0.75); }
}

@media (max-width: 768px) {
  .sheet { width: 100vw; border-left: none; }
  .sheet__head { padding: 0.9rem 1.15rem; }
  .sheet__ledger, .sheet__body { padding-left: 1.15rem; padding-right: 1.15rem; }
  .sheet__title { font-size: 1.35rem; }
}

@media (prefers-reduced-motion: reduce) {
  .sheet__dot { animation: none; }
  .sheet-enter-active, .sheet-leave-active, .scrim-enter-active, .scrim-leave-active { transition: none; }
}
</style>