How to write it
Conventions
- SFC shape.
<template>,<script setup>, scoped<style>. Composition API. Components and composables are auto-imported fromapp/. - Class names. Namespace per component with a block and element:
.sheet__head,.tcpw-btn--ghost. State classes areis-*or bare (.active) to match the app. - Tokens. Consume
var(--*)fromtheme.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 ini18n/vi.jsonanden.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.tsand mount<AISidebar>withuseAIInsightsStream().
<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>
examples/TcpwButton.vue
Button
Squared ink button with four inks and three sizes. Renders as a NuxtLink when given a route.
<TcpwButton variant="ghost" size="sm" @click="open = true">{{ $t('ai.generate') }}</TcpwButton>
<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>
examples/TcpwSectionHead.vue
Section head
Eyebrow, serif title, optional see-all link, hairline. Opens every block on a page.
<TcpwSectionHead :eyebrow="$t('home.markets')" :title="$t('home.earningsThisWeek')" to="/earnings-calendar" />
<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>
examples/TcpwNotice.vue
Notice
Neutral frame; the kind repoints one --notice-ink custom property.
<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>
<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>
examples/TcpwSegmented.vue
Segmented control
Binary control with a sliding ink thumb and arrow-key support.
<TcpwSegmented v-model="cycle" :options="[{ value: 'monthly', label: $t('pricing.monthly') }, { value: 'yearly', label: $t('pricing.yearly'), badge: '-20%' }]" />
<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>
examples/TcpwStatFigure.vue
Statement figures
Borderless KPIs on a subgrid. Primary figure at 3rem, others at 1.75rem, tone from delta.
<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 }]" />
<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>
examples/TcpwDataTable.vue
Data table
Dense zero-radius table with sortable headers, zebra rows, and edge fades on overflow.
<TcpwDataTable :columns="cols" :rows="rows" />
<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>
examples/TcpwSheet.vue
Right sheet
The research-note sheet: teleported, slides from the right, focus-trapped, scroll-locked.
<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>
<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>
