Files
dezky/apps/portal/pages/admin/branding.vue
T
Ronni Baslund 17ffd95a70 chore(portal,operator): upgrade to Nuxt 4
Upgrade both Nuxt apps to Nuxt 4.4.6 (vue-tsc 3, TypeScript 5.6, undici 7) and add a root tsconfig.json to each app. Fix the strict-null / noUncheckedIndexedAccess errors surfaced by Nuxt 4's stricter generated tsconfig and vue-tsc 3. Drop the nuxt-oidc-auth pnpm patch (Nuxt 4 fixes the prepare:types crash natively).
2026-05-30 08:02:43 +02:00

789 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// Strict port of project/platform-screens.jsx `BrandingScreen` (lines 1542-1668)
// with BrandingPreview (1669), UploadAssetModal (1733), EditEmailTemplatePanel
// (1903), PublishBrandingModal (2031) and ResetBrandingModal (2148). Two-column
// layout — controls on the left (420px), live preview on the right.
const toast = useToast()
const color = ref('#D4FF3A')
const name = ref('Acme Workspace')
const uploadAsset = ref<typeof ASSETS[number] | null>(null)
const uploaded = ref(false)
const dragOver = ref(false)
const editTemplate = ref<typeof TEMPLATES[number] | null>(null)
const subject = ref('')
const body = ref('')
const testSent = ref(false)
function sendTest() {
testSent.value = true
setTimeout(() => (testSent.value = false), 2500)
}
const publishOpen = ref(false)
const publishState = ref<'confirm' | 'publishing' | 'done'>('confirm')
const resetOpen = ref(false)
const ASSETS = [
{ id: 'full', l: 'Full logo', d: 'horizontal · 4:1 · png/svg', ratio: '4:1', formats: 'png · svg', maxKb: 400, current: false, currentName: '', currentSize: '' },
{ id: 'mark', l: 'Square mark', d: '1:1 · transparent · png/svg', ratio: '1:1', formats: 'png · svg', maxKb: 200, current: true, currentName: 'acme-mark.svg', currentSize: '12 KB' },
{ id: 'favicon', l: 'Favicon', d: '32×32 · ico/png', ratio: '1:1', formats: 'ico · png', maxKb: 50, current: true, currentName: 'favicon.ico', currentSize: '4 KB' },
] as const
const TEMPLATES = [
{ id: 'invitation', name: 'User invitation', subject: 'Youve been invited to {{workspace.name}}', desc: 'sent when an admin invites a new user', edited: '3 days ago' },
{ id: 'reset', name: 'Password reset', subject: 'Reset your {{workspace.name}} password', desc: 'sent on forgot-password requests', edited: 'default' },
{ id: 'digest', name: 'Notification digest', subject: 'Your weekly summary from {{workspace.name}}', desc: 'sent weekly to users opted-in for digests', edited: '2 weeks ago' },
{ id: 'trial', name: 'Trial expiring', subject: 'Your trial ends in {{trial.days_left}} days', desc: 'sent 7 / 3 / 1 days before trial expiry', edited: 'default' },
] as const
const TEMPLATE_BODIES: Record<string, string> = {
invitation: `Hi {{user.first_name}},
{{inviter.name}} has invited you to join {{workspace.name}} on dezky.
Click below to set up your account — the link expires in 7 days.
→ {{invite.url}}
If you have any questions, reply to this email and we'll help out.
— The {{workspace.name}} team`,
reset: `Hi {{user.first_name}},
Someone (hopefully you) asked to reset your {{workspace.name}} password.
Click the link below within the next 60 minutes to choose a new one:
→ {{reset.url}}
If you didn't request this, you can safely ignore this email.
— {{workspace.name}} security`,
digest: `Hi {{user.first_name}},
Here's what happened in {{workspace.name}} this week:
· {{stats.messages}} new messages across your channels
· {{stats.files}} files shared
· {{stats.meetings}} meetings recorded
→ Open dashboard: {{workspace.url}}
Manage how often you receive these from your profile.`,
trial: `Hi {{user.first_name}},
Your {{workspace.name}} trial ends in {{trial.days_left}} days.
You've added {{stats.users}} users and uploaded {{stats.gb}} GB of files. To keep everything running smoothly, upgrade to Business or Enterprise.
→ Choose a plan: {{billing.url}}
— {{workspace.name}}`,
}
const TEMPLATE_MERGE_TAGS: Record<string, string[]> = {
invitation: ['user.first_name', 'user.email', 'inviter.name', 'workspace.name', 'invite.url', 'invite.expires_at'],
reset: ['user.first_name', 'workspace.name', 'reset.url', 'reset.expires_at', 'security.ip'],
digest: ['user.first_name', 'workspace.name', 'workspace.url', 'stats.messages', 'stats.files', 'stats.meetings'],
trial: ['user.first_name', 'workspace.name', 'trial.days_left', 'stats.users', 'stats.gb', 'billing.url'],
}
const colorPalette = ['#D4FF3A', '#3F6BFF', '#FF6B4A', '#5B8C5A', '#9B59B6']
function openTemplate(t: typeof TEMPLATES[number]) {
editTemplate.value = t
subject.value = t.subject
body.value = TEMPLATE_BODIES[t.id] || ''
testSent.value = false
}
function insertTag(tag: string) {
body.value += `{{${tag}}}`
}
// Reset the currently-open template's subject + body to the canonical default.
function resetTemplate() {
if (!editTemplate.value) return
subject.value = editTemplate.value.subject
body.value = TEMPLATE_BODIES[editTemplate.value.id] || ''
toast.info('Template reset to default')
}
// Wrap a merge-tag name in mustaches via JS so the template doesn't have to
// nest `{{ ... }}` inside `{{ ... }}` (which Vue's parser scans positionally
// and breaks on).
function wrapTag(tag: string) {
return '{' + '{' + tag + '}' + '}'
}
function startPublish() {
publishState.value = 'publishing'
setTimeout(() => { publishState.value = 'done' }, 1800)
}
function openPublish() {
publishOpen.value = true
publishState.value = 'confirm'
}
const renderedSubject = computed(() =>
subject.value
.replace(/\{\{workspace\.name\}\}/g, name.value)
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
.replace(/\{\{trial\.days_left\}\}/g, '3'),
)
const renderedBody = computed(() =>
body.value
.replace(/\{\{workspace\.name\}\}/g, name.value)
.replace(/\{\{workspace\.url\}\}/g, 'workspace.acme.dk')
.replace(/\{\{user\.first_name\}\}/g, 'Anne')
.replace(/\{\{user\.email\}\}/g, 'anne@acme.dk')
.replace(/\{\{inviter\.name\}\}/g, 'Mikkel Nørgaard')
.replace(/\{\{invite\.url\}\}/g, 'workspace.acme.dk/accept/x9k2a')
.replace(/\{\{reset\.url\}\}/g, 'workspace.acme.dk/reset/p2b7c')
.replace(/\{\{billing\.url\}\}/g, 'workspace.acme.dk/billing')
.replace(/\{\{trial\.days_left\}\}/g, '3')
.replace(/\{\{stats\.messages\}\}/g, '1.840')
.replace(/\{\{stats\.files\}\}/g, '24')
.replace(/\{\{stats\.meetings\}\}/g, '6')
.replace(/\{\{stats\.users\}\}/g, '8')
.replace(/\{\{stats\.gb\}\}/g, '14'),
)
</script>
<template>
<div>
<PageHeader
eyebrow="Whitelabel"
title="Branding"
subtitle="Replace the dezky shell with your own logo, color, and product name. Changes propagate everywhere."
>
<template #actions>
<UiButton variant="ghost" @click="resetOpen = true">Reset</UiButton>
<UiButton variant="primary" @click="openPublish">Publish</UiButton>
</template>
</PageHeader>
<div class="content">
<!-- Controls -->
<div class="controls">
<Card>
<div class="card-head"><Eyebrow>Identity</Eyebrow><div class="card-title">Product identity</div></div>
<label class="field"><Eyebrow>Product name (shown to users)</Eyebrow><input class="input" v-model="name" /></label>
<label class="field"><Eyebrow>Custom domain</Eyebrow>
<div class="input-row">
<input value="workspace.acme.dk" readonly />
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
</div>
</label>
</Card>
<Card>
<div class="card-head">
<Eyebrow>Color</Eyebrow>
<div class="card-title">Primary accent</div>
<div class="card-sub">Propagates to buttons, links, focus rings, and active states.</div>
</div>
<div class="swatches">
<button v-for="c in colorPalette" :key="c" :style="{ background: c, borderColor: color === c ? 'var(--text)' : 'var(--border)', borderWidth: color === c ? '2px' : '1px' }" @click="color = c" />
</div>
<div class="input-row">
<input v-model="color" />
<div class="color-preview" :style="{ background: color }" />
</div>
</Card>
<Card>
<div class="card-head"><Eyebrow>Assets</Eyebrow><div class="card-title">Logo upload</div></div>
<div class="assets">
<div v-for="a in ASSETS" :key="a.id" class="asset" :class="{ has: a.current }">
<div class="asset-icon" :style="{ color: a.current ? 'var(--ok)' : 'var(--text-mute)' }">
<UiIcon :name="a.current ? 'check' : 'upload'" :size="16" :stroke-width="a.current ? 2.5 : 2" />
</div>
<div class="asset-meta">
<div class="asset-l">{{ a.l }}</div>
<Mono dim>{{ a.current ? `${a.currentName} · ${a.currentSize}` : a.d }}</Mono>
</div>
<UiButton size="sm" :variant="a.current ? 'ghost' : 'secondary'" @click="uploadAsset = a as any; uploaded = false">
{{ a.current ? 'Replace' : 'Upload' }}
</UiButton>
</div>
</div>
</Card>
<Card>
<div class="card-head"><Eyebrow>Templates</Eyebrow><div class="card-title">Email templates</div></div>
<div class="templates">
<button v-for="t in TEMPLATES" :key="t.id" class="tmpl-row" @click="openTemplate(t as any)">
<div class="tmpl-meta">
<div class="tmpl-name-row">
<span class="tmpl-name">{{ t.name }}</span>
<Badge :tone="t.edited === 'default' ? 'neutral' : 'info'">{{ t.edited === 'default' ? 'default' : 'edited' }}</Badge>
</div>
<Mono dim>edited {{ t.edited }}</Mono>
</div>
<UiIcon name="chevRight" :size="14" stroke="var(--text-mute)" />
</button>
</div>
</Card>
</div>
<!-- Preview -->
<div class="preview-col">
<div class="preview-head">
<Eyebrow>Live preview</Eyebrow>
<Mono dim>workspace.acme.dk</Mono>
</div>
<div class="preview-frame">
<div class="frame-topbar">
<div class="frame-mark" :style="{ background: color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
<div class="frame-brand">{{ name.toLowerCase() }}</div>
<div class="frame-spacer" />
<div class="frame-user">anne@acme.dk</div>
</div>
<div class="frame-hero">
<div class="frame-eyebrow">Dashboard</div>
<div class="frame-title">Good morning, Anne.</div>
<div class="frame-tiles">
<div v-for="n in ['Mail', 'Drev', 'Møder', 'Chat']" :key="n" class="frame-tile">
<div class="frame-tile-icon">{{ n[0] }}</div>
<div class="frame-tile-name">{{ n }}</div>
</div>
</div>
<div class="frame-cta" :style="{ background: color }">
<div>
<div class="frame-cta-title">Welcome to {{ name }}.</div>
<div class="frame-cta-sub">Your team's workspace is ready.</div>
</div>
<button class="frame-cta-btn">Get started</button>
</div>
</div>
<div class="frame-foot">
<span>powered by dezky</span>
<span>v1.0 · light</span>
</div>
</div>
</div>
</div>
<!-- Upload asset modal -->
<Modal :open="!!uploadAsset" :eyebrow="uploadAsset ? `Branding · ${uploadAsset.l.toLowerCase()}` : ''" :title="uploadAsset ? `Upload ${uploadAsset.l.toLowerCase()}` : ''" size="md" @close="uploadAsset = null">
<div v-if="uploadAsset" class="upload">
<button v-if="!uploaded" class="dropzone" :class="{ over: dragOver }"
@dragover.prevent="dragOver = true"
@dragleave="dragOver = false"
@drop.prevent="dragOver = false; uploaded = true"
@click="uploaded = true">
<UiIcon name="upload" :size="28" stroke="var(--text-mute)" />
<div class="drop-text">
<div class="drop-title">Drop {{ uploadAsset.l.toLowerCase() }} here, or click to browse</div>
<Mono dim>{{ uploadAsset.formats }} · {{ uploadAsset.ratio }} ratio · up to {{ uploadAsset.maxKb }} KB</Mono>
</div>
</button>
<template v-if="uploaded">
<div class="upload-preview">
<div class="upload-mark" :style="{ width: uploadAsset.id === 'full' ? '96px' : '56px' }">
{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}
</div>
<div class="upload-meta">
<div class="upload-name">{{ uploadAsset.id === 'favicon' ? 'favicon-new.png' : uploadAsset.id === 'mark' ? 'acme-mark-v2.svg' : 'acme-logo.svg' }}</div>
<Mono dim>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} · {{ uploadAsset.id === 'favicon' ? '32×32' : uploadAsset.id === 'mark' ? '512×512' : '1200×300' }} · clean alpha</Mono>
</div>
<UiButton size="sm" variant="ghost" @click="uploaded = false">Replace</UiButton>
</div>
<Eyebrow>Looks good</Eyebrow>
<div class="check-list">
<div class="check-row">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<Mono dim>Format</Mono>
<span>{{ uploadAsset.formats.split(' · ')[0] }} ✓</span>
</div>
<div class="check-row">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<Mono dim>Dimensions</Mono>
<span>{{ uploadAsset.id === 'favicon' ? '32×32 ' : uploadAsset.ratio + ' ' }}</span>
</div>
<div class="check-row">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<Mono dim>Size</Mono>
<span>{{ uploadAsset.id === 'favicon' ? '6 KB' : uploadAsset.id === 'mark' ? '14 KB' : '38 KB' }} (under {{ uploadAsset.maxKb }} KB)</span>
</div>
<div class="check-row">
<UiIcon name="check" :size="12" stroke="var(--ok)" :stroke-width="2.5" />
<Mono dim>Transparency</Mono>
<span>{{ uploadAsset.id === 'favicon' ? 'opaque background OK' : 'transparent background ' }}</span>
</div>
</div>
<div class="ld-preview">
<Eyebrow>Preview · on light + dark</Eyebrow>
<div class="ld-grid">
<div class="ld-light">
<div class="ld-mark dark" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
</div>
<div class="ld-dark">
<div class="ld-mark light" :style="{ width: uploadAsset.id === 'full' ? '80px' : '32px' }">{{ uploadAsset.id === 'full' ? 'acme' : 'a' }}</div>
</div>
</div>
</div>
</template>
<div class="req-box">
<Mono dim>// requirements</Mono>
<div class="req-body">
<template v-if="uploadAsset.id === 'full'">Used in the top navigation bar, login screen, and email headers. Roughly 200×50 displayed — supply at 2× minimum.</template>
<template v-else-if="uploadAsset.id === 'mark'">Used as the app icon, favicon fallback, and any compact context (PWA install, notifications). Must read at 24×24.</template>
<template v-else>Browser tab icon and bookmark badge. 32×32 is the standard size — modern browsers use the same file at 16×16.</template>
</div>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="uploadAsset = null">Cancel</UiButton>
<UiButton variant="primary" :disabled="!uploaded" @click="uploadAsset = null">
<template #leading><UiIcon name="check" :size="13" /></template>
{{ uploaded ? 'Use this asset' : 'Select a file to continue' }}
</UiButton>
</template>
</Modal>
<!-- Edit email template side panel -->
<SidePanel :open="!!editTemplate" :eyebrow="'Email template'" :title="editTemplate?.name || ''" width="lg" @close="editTemplate = null">
<div v-if="editTemplate" class="tmpl-edit">
<div class="tmpl-col">
<label class="field"><Eyebrow>Subject</Eyebrow><input class="input" v-model="subject" /></label>
<div>
<Eyebrow>Body</Eyebrow>
<textarea v-model="body" class="body-area" />
</div>
<div>
<Eyebrow>Merge tags · click to insert</Eyebrow>
<div class="merge-tags">
<button v-for="tag in (TEMPLATE_MERGE_TAGS[editTemplate.id] || [])" :key="tag" @click="insertTag(tag)">{{ wrapTag(tag) }}</button>
</div>
</div>
</div>
<div class="tmpl-prev">
<Eyebrow>Preview</Eyebrow>
<div class="email-frame">
<div class="email-head">
<div class="from-row">
<div class="from-mark" :style="{ background: '#0A0A0A', color }">{{ name[0]?.toLowerCase() || 'a' }}</div>
<Mono dim>From: {{ name.toLowerCase().replace(/\s+/g, '-') }}@dezky.com</Mono>
</div>
<div class="email-subj">{{ renderedSubject }}</div>
</div>
<div class="email-body">{{ renderedBody }}</div>
<div class="email-foot" :style="{ background: color }">{{ name }} · workspace.acme.dk</div>
</div>
<Mono dim style="text-align: center; display: block;">preview substitutes sample data · real send uses recipient's data</Mono>
</div>
</div>
<template #footer>
<UiButton variant="ghost" @click="resetTemplate">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Reset to default
</UiButton>
<div style="flex: 1" />
<UiButton variant="secondary" @click="sendTest">
<template #leading><UiIcon name="mail" :size="13" /></template>
{{ testSent ? 'Sent to anne@dezky.com ✓' : 'Send test to me' }}
</UiButton>
<UiButton variant="primary" @click="editTemplate = null">
<template #leading><UiIcon name="check" :size="13" /></template>
Save template
</UiButton>
</template>
</SidePanel>
<!-- Publish modal -->
<Modal :open="publishOpen" eyebrow="Branding · publish" :title="publishState === 'done' ? 'Branding published' : 'Publish branding changes?'" size="md" @close="publishState !== 'publishing' ? (publishOpen = false) : null">
<template v-if="publishState === 'confirm'">
<div class="publish-intro">These changes will replace dezky's branding for everyone in your workspace within ~30 seconds.</div>
<Eyebrow>Will go live</Eyebrow>
<div class="publish-summary">
<div class="ps-row"><Mono dim>Product name</Mono><span>{{ name }}</span></div>
<div class="ps-row">
<Mono dim>Primary color</Mono>
<span class="color-line">
<span class="color-chip" :style="{ background: color }" />
<Mono>{{ color }}</Mono>
</span>
</div>
<div class="ps-row">
<Mono dim>Custom domain</Mono>
<Mono>workspace.acme.dk</Mono>
<Badge tone="ok" dot>verified</Badge>
</div>
</div>
<Eyebrow>Propagates to</Eyebrow>
<div class="prop-grid">
<div v-for="[k, t] in [
['Web app · workspace shell', '~10s'],
['Login + auth pages', '~10s'],
['Outbound email templates', '~30s'],
['Mobile app · next session', 'on next launch'],
['Status page', '~30s'],
['PDF invoices', 'next billing cycle'],
]" :key="k" class="prop-cell">
<UiIcon name="check" :size="11" stroke="var(--ok)" :stroke-width="2.5" />
<span>{{ k }}</span>
<Mono dim>{{ t }}</Mono>
</div>
</div>
<div class="publish-warn">
<UiIcon name="shield" :size="14" stroke="var(--warn)" />
<div>Users may need to hard-refresh to see the new branding immediately. You can revert with one click for the next 7 days.</div>
</div>
</template>
<template v-else-if="publishState === 'publishing'">
<div class="publishing">
<div class="spinner" />
<div class="publish-title">Publishing across services…</div>
<Mono dim>web shell · auth · mail templates · CDN</Mono>
</div>
</template>
<template v-else>
<div class="done-head">
<div class="done-badge" :style="{ background: color }">
<UiIcon name="check" :size="20" :stroke-width="2.5" />
</div>
<div>
<div class="publish-title">{{ name }} branding is live</div>
<Mono dim>5 services updated · 1 queued for next cycle</Mono>
</div>
</div>
<div class="done-list">
<dl class="def">
<div><dt>Web app + auth</dt><dd>live · 8 seconds</dd></div>
<div><dt>Email templates</dt><dd>live · 18 seconds</dd></div>
<div><dt>Mobile · status · CDN</dt><dd>queued · ~30s</dd></div>
<div><dt>PDF invoices</dt><dd>starts 01 Jun 2026</dd></div>
</dl>
</div>
</template>
<template #footer>
<template v-if="publishState === 'confirm'">
<UiButton variant="ghost" @click="publishOpen = false">Cancel</UiButton>
<UiButton variant="primary" @click="startPublish">
<template #leading><UiIcon name="external" :size="13" /></template>
Publish now
</UiButton>
</template>
<template v-else-if="publishState === 'publishing'">
<UiButton variant="ghost" disabled>Publishing…</UiButton>
</template>
<template v-else>
<UiButton variant="primary" @click="publishOpen = false">Done</UiButton>
</template>
</template>
</Modal>
<!-- Reset branding modal -->
<Modal :open="resetOpen" eyebrow="Destructive · reverts to defaults" title="Reset branding to dezky defaults?" size="sm" @close="resetOpen = false">
<div class="reset-box bad">
<UiIcon name="shield" :size="16" stroke="var(--bad)" />
<div>Reverts product name, colors, logos, and email templates to dezky defaults. Your custom domain stays connected. Edits made today are kept for 7 days and can be restored from your audit log.</div>
</div>
<div class="reset-list">
<dl class="def">
<div><dt>Product name</dt><dd>Acme Workspace → dezky</dd></div>
<div><dt>Primary color</dt><dd>#D4FF3A → #D4FF3A (default)</dd></div>
<div><dt>Full logo</dt><dd>will be removed</dd></div>
<div><dt>Square mark</dt><dd>will be removed</dd></div>
<div><dt>Favicon</dt><dd>will be removed</dd></div>
<div><dt>Email templates</dt><dd>2 edited templates → defaults</dd></div>
<div><dt>Custom domain</dt><dd>workspace.acme.dk · kept</dd></div>
</dl>
</div>
<template #footer>
<UiButton variant="ghost" @click="resetOpen = false">Cancel</UiButton>
<UiButton variant="danger" @click="resetOpen = false">
<template #leading><UiIcon name="refresh" :size="13" /></template>
Reset everything
</UiButton>
</template>
</Modal>
</div>
</template>
<style scoped>
.content { padding: 24px 40px 64px 40px; display: grid; grid-template-columns: 420px 1fr; gap: 24px; }
.controls { display: flex; flex-direction: column; gap: 16px; }
.card-head { margin-bottom: 14px; }
.card-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; letter-spacing: -0.01em; margin-top: 4px; }
.card-sub { font-size: 13px; color: var(--text-mute); margin-top: 4px; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.field:last-child { margin-bottom: 0; }
.input { height: 36px; padding: 0 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: 13px; color: var(--text); outline: none; }
.input:focus { border-color: var(--text); }
.input-row {
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}
.input-row input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 13px; color: var(--text); }
.color-preview { width: 36px; height: 36px; border-radius: 6px; border: 1px solid var(--border); flex-shrink: 0; }
.swatches { display: flex; gap: 10px; margin-bottom: 14px; }
.swatches button { width: 38px; height: 38px; border-radius: 6px; cursor: pointer; }
.assets { display: flex; flex-direction: column; gap: 10px; }
.asset {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px dashed var(--border);
border-radius: 6px;
}
.asset.has { background: var(--surface); border-style: solid; }
.asset-icon { width: 40px; height: 40px; border-radius: 6px; background: var(--bg); display: inline-flex; align-items: center; justify-content: center; }
.asset-meta { flex: 1; min-width: 0; }
.asset-l { font-size: 13px; font-weight: 500; }
.templates { display: flex; flex-direction: column; }
.tmpl-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); background: transparent; border-left: none; border-right: none; border-top: none; text-align: left; color: var(--text); font-family: inherit; font-size: 13px; cursor: pointer; }
.tmpl-row:last-child { border-bottom: none; }
.tmpl-meta { flex: 1; min-width: 0; }
.tmpl-name-row { display: flex; align-items: center; gap: 8px; }
.tmpl-name { font-weight: 500; }
.preview-col { min-width: 0; }
.preview-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.preview-frame {
background: #FAFAF7;
color: #0A0A0A;
border-radius: 10px;
border: 1px solid var(--border);
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
font-family: 'Inter', sans-serif;
}
.frame-topbar {
height: 52px;
background: #0A0A0A;
color: #F4F3EE;
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
}
.frame-mark {
width: 24px;
height: 24px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 13px;
color: #0A0A0A;
}
.frame-brand { font-family: var(--font-mono); font-size: 13px; font-weight: 600; }
.frame-spacer { flex: 1; }
.frame-user { font-size: 11px; font-family: var(--font-mono); opacity: 0.6; }
.frame-hero { padding: 36px 32px 24px 32px; }
.frame-eyebrow { font-family: var(--font-mono); font-size: 10px; color: #5A5A55; letter-spacing: 0.12em; text-transform: uppercase; }
.frame-title { font-family: var(--font-display); font-size: 28px; font-weight: 600; letter-spacing: -0.02em; margin-top: 8px; }
.frame-tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 20px; }
.frame-tile { background: #fff; border: 1px solid #E6E4DC; border-radius: 6px; padding: 14px; }
.frame-tile-icon {
width: 24px;
height: 24px;
border-radius: 5px;
background: #0A0A0A;
color: #F4F3EE;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 11px;
}
.frame-tile-name { font-family: var(--font-display); font-weight: 600; font-size: 14px; margin-top: 12px; }
.frame-cta { margin-top: 24px; padding: 18px 20px; border-radius: 6px; display: flex; align-items: center; justify-content: space-between; }
.frame-cta-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; color: #0A0A0A; }
.frame-cta-sub { font-size: 12px; color: rgba(10, 10, 10, 0.7); margin-top: 4px; }
.frame-cta-btn { height: 32px; padding: 0 14px; border-radius: 5px; border: none; background: #0A0A0A; color: #F4F3EE; font-weight: 600; font-size: 12px; cursor: pointer; }
.frame-foot { padding: 12px 32px; border-top: 1px solid #E6E4DC; background: #F4F3EE; font-size: 11px; color: #5A5A55; font-family: var(--font-mono); display: flex; justify-content: space-between; }
/* Upload modal */
.upload { display: flex; flex-direction: column; gap: 14px; }
.dropzone {
padding: 48px 24px;
background: var(--bg);
border: 2px dashed var(--border);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.dropzone.over { background: var(--surface); border-color: var(--text); }
.drop-text { text-align: center; }
.drop-title { font-size: 14px; font-weight: 500; color: var(--text); }
.upload-preview {
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
align-items: center;
gap: 14px;
}
.upload-mark {
height: 56px;
background: var(--text);
color: var(--bg);
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 18px;
flex-shrink: 0;
}
.upload-meta { flex: 1; min-width: 0; }
.upload-name { font-size: 13px; font-weight: 500; }
.check-list { display: flex; flex-direction: column; gap: 6px; }
.check-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; }
.check-row > :first-of-type { flex-shrink: 0; }
.ld-preview { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
.ld-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
.ld-light, .ld-dark { border-radius: 6px; padding: 18px; display: flex; align-items: center; justify-content: center; }
.ld-light { background: #FAFAF7; }
.ld-dark { background: #0A0A0A; }
.ld-mark {
height: 32px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 12px;
}
.ld-mark.dark { background: #0A0A0A; color: #F4F3EE; }
.ld-mark.light { background: #F4F3EE; color: #0A0A0A; }
.req-box { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); font-size: 12px; color: var(--text-mute); line-height: 1.55; }
.req-body { margin-top: 6px; }
/* Email template editor */
.tmpl-edit { display: grid; grid-template-columns: 1fr 1fr; min-height: 0; height: 100%; }
.tmpl-col { padding: 24px; border-right: 1px solid var(--border); display: flex; flex-direction: column; gap: 14px; }
.tmpl-prev { padding: 24px; background: var(--bg); display: flex; flex-direction: column; gap: 12px; }
.body-area {
width: 100%;
min-height: 320px;
padding: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
color: var(--text);
font-family: var(--font-mono);
line-height: 1.6;
resize: vertical;
outline: none;
box-sizing: border-box;
}
.merge-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.merge-tags button {
padding: 4px 8px;
border-radius: 4px;
background: var(--surface);
border: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text);
cursor: pointer;
}
.email-frame {
background: #fff;
border-radius: 8px;
overflow: hidden;
border: 1px solid #E6E4DC;
color: #0A0A0A;
font-family: 'Inter', sans-serif;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
flex: 1;
display: flex;
flex-direction: column;
}
.email-head { padding: 16px 20px; border-bottom: 1px solid #E6E4DC; background: #FAFAF7; }
.from-row { display: flex; align-items: center; gap: 8px; }
.from-mark {
width: 22px;
height: 22px;
border-radius: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
font-size: 11px;
}
.email-subj { font-family: var(--font-display); font-weight: 600; font-size: 16px; margin-top: 10px; color: #0A0A0A; }
.email-body { padding: 20px; font-size: 13px; line-height: 1.65; color: #3A3A35; white-space: pre-wrap; flex: 1; overflow-y: auto; }
.email-foot { padding: 14px 20px; border-top: 1px solid #E6E4DC; color: #0A0A0A; font-size: 11px; font-family: var(--font-mono); text-align: center; }
/* Publish modal */
.publish-intro { font-size: 13px; color: var(--text-dim); line-height: 1.55; margin-bottom: 14px; }
.publish-summary { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; margin-top: 8px; margin-bottom: 14px; }
.ps-row { display: flex; align-items: center; gap: 12px; }
.ps-row > :first-child { width: 100px; }
.color-line { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; }
.color-chip { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border); }
.prop-grid { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 12px; margin-top: 8px; margin-bottom: 14px; }
.prop-cell { display: flex; align-items: center; gap: 8px; }
.prop-cell span:first-of-type { flex: 1; }
.publish-warn { padding: 12px; background: rgba(232, 154, 31, 0.06); border-radius: 6px; border: 1px solid rgba(232, 154, 31, 0.2); font-size: 12px; color: var(--text-dim); line-height: 1.55; display: flex; gap: 10px; }
.publishing { padding: 32px 0; text-align: center; }
.spinner {
width: 56px;
height: 56px;
margin: 0 auto 18px auto;
border-radius: 999px;
border: 3px solid var(--border);
border-top-color: var(--accent);
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg) } }
.publish-title { font-family: var(--font-display); font-size: 20px; font-weight: 600; }
.done-head { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
.done-badge {
width: 44px;
height: 44px;
border-radius: 10px;
color: #0A0A0A;
display: inline-flex;
align-items: center;
justify-content: center;
}
.done-list { padding: 14px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
.def { margin: 0; display: grid; grid-template-columns: 140px 1fr; row-gap: 12px; column-gap: 16px; }
.def > div { display: contents; }
.def dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); }
.def dd { margin: 0; font-size: 13px; color: var(--text); }
/* Reset modal */
.reset-box { padding: 14px; border-radius: 6px; display: flex; gap: 10px; align-items: flex-start; margin-bottom: 14px; }
.reset-box.bad { background: rgba(226, 48, 48, 0.06); border: 1px solid rgba(226, 48, 48, 0.2); }
.reset-box > div { font-size: 13px; color: var(--text-dim); line-height: 1.5; }
.reset-list { padding: 12px; background: var(--bg); border-radius: 6px; border: 1px solid var(--border); }
</style>