feat(operator): command palette, impersonation, incident, tweaks (O.8)

- CommandPalette + useCommandPalette: ⌘K opens a search-and-jump panel over
  real tenants/partners + fixture flags + nav + actions. Arrow keys + Enter
  navigate, Escape/backdrop close. Recents are intentionally omitted for now;
  add when there's something to recent over.
- Impersonation stub: useImpersonation + ImpersonationModal + ImpersonationBanner.
  Modal opens from tenant detail and from the palette. Banner stays at the top
  of the shell until exited. No real OBO token is minted — wiring OAuth Token
  Exchange is tracked as a follow-up.
- IncidentModal + useIncidentModal: opened from the Overview and Infrastructure
  incident banners, renders the mock INCIDENT data with metrics, timeline and
  draft composer.
- TweaksPanel + useTweaks: floating bottom-right panel for theme (dark/light),
  density (comfy/compact), env badge (prod/staging/dev). Saved to localStorage.
- Theme/density apply via [data-theme] + [data-density] overrides in
  tokens.css. Topbar env badge now reads from useTweaks instead of a prop.
- Layout wires ⌘K + ⌘[ at the document level and mounts the palette + modals
  + banner + tweaks panel once for all pages.
This commit is contained in:
Ronni Baslund
2026-05-24 08:34:34 +02:00
parent e0ac643e80
commit c71e782dc0
16 changed files with 1162 additions and 30 deletions
+43
View File
@@ -37,4 +37,47 @@
--font-mono: 'JetBrains Mono', ui-monospace, 'Menlo', monospace;
--input-bg: rgba(244, 243, 238, 0.04);
/* Cosmetic density. comfy=1, compact≈0.78. Used by page chrome that opts in
(PageHeader / table cells). Reading via calc() keeps layouts in one place. */
--density-scale: 1;
}
/* Tweaks: density overrides */
:root[data-density='compact'] { --density-scale: 0.78; }
/* Tweaks: light theme (warm cream, charcoal text). Overrides every surface
token so any component that uses var(--bg / --surface / --text / ...) flips
without code changes. Components that hard-code rgba(244,243,238,...) will
not flip — those should switch to tokens if they care about light mode. */
:root[data-theme='light'] {
--bg: #F6F4EF;
--surface: #FAF8F2;
--elevated: #FFFFFF;
--border: #E2DED2;
--border-hi: #D0CBBC;
--text: #1C1B17;
--text-dim: rgba(28, 27, 23, 0.72);
--text-mute: rgba(28, 27, 23, 0.50);
--side-bg: #F0EDE4;
--side-surf: #FAF8F2;
--side-border: #E2DED2;
--side-text: #1C1B17;
--side-dim: rgba(28, 27, 23, 0.62);
--side-mute: rgba(28, 27, 23, 0.42);
--side-hover: rgba(28, 27, 23, 0.05);
--side-active: rgba(28, 27, 23, 0.08);
--accent: #1F8A5B;
--accent-fg: #FAF8F2;
--signal: #1F8A5B;
--ok: #1F8A5B;
--warn: #C97F1F;
--bad: #C03A3A;
--info: #2A6FDB;
--input-bg: rgba(28, 27, 23, 0.04);
}
+366
View File
@@ -0,0 +1,366 @@
<script setup lang="ts">
import type { IconName } from './UiIcon.vue'
import type { Tenant } from '~/types/tenant'
import type { Partner } from '~/types/partner'
import { FLAGS, INCIDENT } from '~/data/fixtures'
// Each row in the palette. `action` decides what happens on Enter / click.
// We try `to` first (a navigateTo) since most rows are navigation; `run` is
// the escape hatch for actions that aren't a route (open modal, toast, etc.).
interface Row {
id: string
groupLabel: string
title: string
subtitle?: string
icon?: IconName
badge?: string
to?: string
run?: () => void
}
const { isOpen, close } = useCommandPalette()
const { open: openImpersonation } = useImpersonation()
const { open: openIncident } = useIncidentModal()
const query = ref('')
const cursor = ref(0)
const inputRef = ref<HTMLInputElement | null>(null)
// Only fetch when the palette has been opened at least once, then refresh on
// every open so newly-created tenants/partners show up. SSR is disabled (the
// palette is client-only).
const { data: tenants, refresh: rT } = useLazyFetch<Tenant[]>('/api/tenants', { default: () => [], server: false })
const { data: partners, refresh: rP } = useLazyFetch<Partner[]>('/api/partners', { default: () => [], server: false })
const NAV: { id: string; label: string; icon: IconName; to: string }[] = [
{ id: 'n-overview', label: 'Overview', icon: 'home', to: '/' },
{ id: 'n-tenants', label: 'Tenants', icon: 'building', to: '/tenants' },
{ id: 'n-partners', label: 'Partners', icon: 'briefcase', to: '/partners' },
{ id: 'n-users', label: 'Users (global)', icon: 'users', to: '/users' },
{ id: 'n-support', label: 'Support', icon: 'help', to: '/support' },
{ id: 'n-billing', label: 'Platform billing', icon: 'card', to: '/billing' },
{ id: 'n-reports', label: 'Reports', icon: 'database', to: '/reports' },
{ id: 'n-infra', label: 'Infrastructure', icon: 'plug', to: '/infrastructure' },
{ id: 'n-flags', label: 'Feature flags', icon: 'shield', to: '/flags' },
{ id: 'n-audit', label: 'Audit log', icon: 'file', to: '/audit' },
{ id: 'n-team', label: 'Operator team', icon: 'users', to: '/operator-team' },
{ id: 'n-settings', label: 'Platform settings',icon: 'shield', to: '/settings' },
]
function tenantRows(): Row[] {
return (tenants.value ?? []).map((t) => ({
id: `t-${t._id}`,
groupLabel: 'Tenants',
title: t.name,
subtitle: `${t.domains?.[0] ?? t.slug} · ${t.plan} · ${t.status}`,
icon: 'building',
badge: t.status,
to: `/tenants/${t.slug}`,
}))
}
function partnerRows(): Row[] {
return (partners.value ?? []).map((p) => ({
id: `p-${p._id}`,
groupLabel: 'Partners',
title: p.name,
subtitle: `${p.domain} · ${p.customers} customers · ${p.marginPct}% margin`,
icon: 'briefcase',
badge: p.status,
to: `/partners/${p.slug}`,
}))
}
function flagRows(): Row[] {
return FLAGS.slice(0, 6).map((f) => ({
id: `f-${f.key}`,
groupLabel: 'Feature flags',
title: f.key,
subtitle: f.state === 'rollout' ? `rollout · ${f.pct}% · ${f.scope}` : `${f.state} · ${f.scope}`,
icon: 'plug',
to: '/flags',
}))
}
function navRows(): Row[] {
return NAV.map((n) => ({
id: n.id, groupLabel: 'Navigation', title: n.label, subtitle: 'navigate', icon: n.icon, to: n.to,
}))
}
function actionRows(): Row[] {
const firstTenant = tenants.value?.[0]
return [
{
id: 'a-impersonate',
groupLabel: 'Actions',
title: 'Impersonate user…',
subtitle: firstTenant ? `enter ${firstTenant.name} as a user` : 'no tenants yet',
icon: 'key',
run: () => firstTenant && openImpersonation(firstTenant),
},
{
id: 'a-incident',
groupLabel: 'Actions',
title: 'Open active incident',
subtitle: INCIDENT.title,
icon: 'bell',
run: () => openIncident(),
},
{
id: 'a-toggle-theme',
groupLabel: 'Actions',
title: 'Toggle theme',
subtitle: 'dark / light',
icon: 'shield',
run: () => {
const { state, setTheme } = useTweaks()
setTheme(state.value.theme === 'dark' ? 'light' : 'dark')
},
},
]
}
interface Group { label: string; items: Row[] }
const groups = computed<Group[]>(() => {
const q = query.value.trim().toLowerCase()
const all: Row[] = [
...tenantRows(),
...partnerRows(),
...flagRows(),
...navRows(),
...actionRows(),
]
const matches = q
? all.filter((r) => r.title.toLowerCase().includes(q) || (r.subtitle ?? '').toLowerCase().includes(q))
: all
const grouped = new Map<string, Row[]>()
for (const r of matches) {
const arr = grouped.get(r.groupLabel) ?? []
arr.push(r)
grouped.set(r.groupLabel, arr)
}
// Stable group order
const order = ['Tenants', 'Partners', 'Feature flags', 'Navigation', 'Actions']
return order
.filter((g) => grouped.has(g))
.map((g) => ({ label: g, items: (grouped.get(g) ?? []).slice(0, 8) }))
})
const flat = computed(() => groups.value.flatMap((g) => g.items))
watch(isOpen, async (v) => {
if (!v) return
query.value = ''
cursor.value = 0
await Promise.all([rT(), rP()])
await nextTick()
inputRef.value?.focus()
})
watch(query, () => {
cursor.value = 0
})
function activate(row?: Row) {
if (!row) return
if (row.run) row.run()
if (row.to) navigateTo(row.to)
close()
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
close()
}
if (e.key === 'ArrowDown') {
e.preventDefault()
cursor.value = Math.min(cursor.value + 1, Math.max(0, flat.value.length - 1))
}
if (e.key === 'ArrowUp') {
e.preventDefault()
cursor.value = Math.max(0, cursor.value - 1)
}
if (e.key === 'Enter') {
e.preventDefault()
activate(flat.value[cursor.value])
}
}
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="palette-backdrop" @click="close">
<div class="palette" role="dialog" aria-label="Command palette" @click.stop>
<div class="input-row">
<UiIcon name="search" :size="14" />
<input
ref="inputRef"
v-model="query"
type="text"
placeholder="Search tenants, partners, actions…"
autocomplete="off"
@keydown="onKey"
/>
<span class="kbd">ESC</span>
</div>
<div class="results">
<template v-if="groups.length">
<div v-for="g in groups" :key="g.label" class="group">
<div class="group-label">{{ g.label }}</div>
<button
v-for="item in g.items"
:key="item.id"
type="button"
:class="['row', { active: item.id === flat[cursor]?.id }]"
@mouseenter="cursor = flat.findIndex((x) => x.id === item.id)"
@click="activate(item)"
>
<span class="icon-tile">
<UiIcon v-if="item.icon" :name="item.icon" :size="13" />
</span>
<span class="row-body">
<span class="row-title">{{ item.title }}</span>
<span v-if="item.subtitle" class="row-sub">{{ item.subtitle }}</span>
</span>
<Mono v-if="item.badge" dim>{{ item.badge }}</Mono>
</button>
</div>
</template>
<div v-else class="empty">
<Mono dim>// no matches for "{{ query }}"</Mono>
</div>
</div>
<footer>
<Mono dim>{{ flat.length }} {{ flat.length === 1 ? 'result' : 'results' }}</Mono>
<Mono dim> navigate · open · esc close</Mono>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.palette-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 15vh 24px 24px 24px;
z-index: 200;
}
.palette {
width: 100%;
max-width: 640px;
background: var(--elevated);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
max-height: 70vh;
overflow: hidden;
}
.input-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
color: var(--text-mute);
}
.input-row input {
flex: 1;
border: 0;
outline: 0;
background: transparent;
font-family: inherit;
font-size: 15px;
color: var(--text);
}
.kbd {
font-family: var(--font-mono);
font-size: 10px;
padding: 2px 6px;
background: var(--surface);
border-radius: 3px;
color: var(--text-mute);
letter-spacing: 0.04em;
}
.results {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.group { margin-bottom: 4px; }
.group-label {
padding: 10px 14px 4px 14px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 500;
color: var(--text-mute);
}
.row {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px 14px;
background: transparent;
border: 0;
border-left: 2px solid transparent;
border-radius: 6px;
color: var(--text);
font-family: inherit;
cursor: pointer;
text-align: left;
}
.row.active {
background: rgba(212, 255, 58, 0.08);
border-left-color: var(--accent);
}
.icon-tile {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-mute);
flex-shrink: 0;
}
.row.active .icon-tile { color: var(--accent); }
.row-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.row-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row-sub {
font-size: 11px;
color: var(--text-mute);
font-family: var(--font-mono);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty { padding: 40px 18px; text-align: center; }
footer {
padding: 8px 14px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
@@ -0,0 +1,86 @@
<script setup lang="ts">
// Persistent red banner at the very top of the operator chrome. Renders
// whenever useImpersonation().active is set. Sits OUTSIDE the sidebar/topbar
// so it spans the full window width.
const { active, asUser, exit } = useImpersonation()
</script>
<template>
<div v-if="active" class="banner" role="alert">
<span class="pill">
<span class="dot" />
Operator impersonation
</span>
<div class="body">
Viewing <strong>{{ active.name }}</strong> as <strong>{{ asUser }}</strong>.
Every action you take is logged with your operator identity.
</div>
<button class="log-note" type="button">Log note</button>
<button class="exit" type="button" @click="exit">
<UiIcon name="x" :size="12" />
Exit impersonation
</button>
</div>
</template>
<style scoped>
.banner {
background: #9F1D1D;
color: #FEEEEE;
padding: 8px 20px;
display: flex;
align-items: center;
gap: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
animation: impersonationGlow 1.4s ease-in-out infinite alternate;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.14);
border-radius: 4px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 700;
}
.dot { width: 6px; height: 6px; border-radius: 999px; background: #fff; }
.body { flex: 1; min-width: 0; font-size: 13px; }
.body strong { font-weight: 700; }
.log-note, .exit {
border: 0;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
border-radius: 4px;
}
.log-note {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #FEEEEE;
font-size: 12px;
padding: 5px 12px;
}
.exit {
background: #FEEEEE;
color: #9F1D1D;
font-size: 12px;
font-weight: 600;
padding: 6px 14px;
display: inline-flex;
align-items: center;
gap: 6px;
}
@keyframes impersonationGlow {
from { box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.1); }
to { box-shadow: inset 0 -1px 0 rgba(255, 200, 200, 0.3); }
}
</style>
@@ -0,0 +1,158 @@
<script setup lang="ts">
const { candidate, cancel, confirm } = useImpersonation()
const reason = ref('Customer reported issue — investigating')
const asUser = ref('first-user@example.com · Owner')
watch(candidate, (v) => {
if (v) {
reason.value = 'Customer reported issue — investigating'
}
})
function onConfirm() {
if (!reason.value.trim()) return
confirm(reason.value.trim(), asUser.value)
}
</script>
<template>
<Teleport to="body">
<div v-if="candidate" class="backdrop" @click="cancel">
<div class="modal" role="dialog" :aria-label="`Impersonate inside ${candidate.name}`" @click.stop>
<header>
<div>
<Eyebrow>Operator action · logged</Eyebrow>
<h2>Impersonate inside {{ candidate.name }}</h2>
</div>
<button class="x" type="button" aria-label="Close" @click="cancel">
<UiIcon name="x" :size="12" />
</button>
</header>
<div class="body">
<div class="warn">
<UiIcon name="shield" :size="15" />
<p>
You'll see <strong>{{ candidate.name }}</strong>'s workspace exactly as one of their users sees it.
Any state-changing action will be flagged in their audit log as an operator action with your name
attached. <Mono dim>// stub — no real session is created</Mono>
</p>
</div>
<label class="field">
<span>Impersonate as</span>
<input v-model="asUser" type="text" />
</label>
<label class="field">
<span>Reason (required)</span>
<input v-model="reason" type="text" />
</label>
<Mono dim class="note">// logged as op.impersonate · session expires after 30 min idle</Mono>
</div>
<footer>
<UiButton variant="ghost" @click="cancel">Cancel</UiButton>
<UiButton variant="danger" :disabled="!reason.trim()" @click="onConfirm">
<template #leading><UiIcon name="key" :size="13" /></template>
Enter impersonation mode
</UiButton>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 180;
}
.modal {
width: 100%;
max-width: 520px;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid var(--border);
}
h2 { margin: 4px 0 0 0; font-family: var(--font-display); font-weight: 600; font-size: 17px; }
.x {
width: 26px; height: 26px;
border: 0; border-radius: 6px; background: transparent;
color: var(--text-mute); cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.x:hover { background: var(--surface); color: var(--text); }
.body {
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.warn {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
background: rgba(226, 48, 48, 0.06);
border: 1px solid rgba(226, 48, 48, 0.24);
border-left: 3px solid var(--bad);
border-radius: 6px;
color: var(--bad);
}
.warn p { margin: 0; font-size: 12px; color: var(--text-dim); line-height: 1.55; }
.warn strong { color: var(--text); }
.field { display: flex; flex-direction: column; gap: 6px; }
.field span {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
}
.field input {
height: 34px;
padding: 0 12px;
border: 1px solid var(--border);
background: var(--input-bg);
color: var(--text);
border-radius: 6px;
font-family: inherit;
font-size: 13px;
outline: 0;
}
.field input:focus { border-color: var(--border-hi); }
.note { padding-top: 2px; }
footer {
padding: 12px 20px;
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--border);
background: var(--surface);
}
</style>
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { INCIDENT } from '~/data/fixtures'
const { isOpen, close } = useIncidentModal()
const draft = ref('')
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen.value) close()
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
})
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="backdrop" @click="close">
<div class="modal" role="dialog" :aria-label="INCIDENT.title" @click.stop>
<header>
<div>
<Eyebrow>{{ INCIDENT.id }} · {{ INCIDENT.severity }}</Eyebrow>
<h2>{{ INCIDENT.title }}</h2>
</div>
<button class="x" type="button" aria-label="Close" @click="close">
<UiIcon name="x" :size="12" />
</button>
</header>
<div class="body">
<div class="metrics">
<MetricCell label="status" :value="INCIDENT.state" tone="warn" />
<MetricCell label="started" :value="INCIDENT.started" />
<MetricCell label="duration" :value="INCIDENT.duration" />
<MetricCell label="affected" value="12 tenants" />
<MetricCell label="incident lead" :value="INCIDENT.ic" />
<MetricCell label="severity" :value="INCIDENT.severity" tone="bad" />
</div>
<Eyebrow>Updates</Eyebrow>
<div class="timeline">
<div v-for="(u, i) in INCIDENT.updates" :key="i" class="upd">
<span class="bullet" :class="{ first: i === 0 }" />
<div>
<div class="upd-head">
<Mono>{{ u.t }}</Mono>
<span class="who">{{ u.who }}</span>
</div>
<p>{{ u.msg }}</p>
</div>
</div>
</div>
<div class="compose">
<input v-model="draft" type="text" placeholder="Add update… (markdown supported)" />
<div class="compose-foot">
<Mono dim>// will notify on-call + post to #incidents</Mono>
<UiButton variant="primary" :disabled="!draft.trim()">Post update</UiButton>
</div>
</div>
</div>
<footer>
<UiButton variant="ghost" @click="close">Close</UiButton>
<UiButton variant="secondary">
<template #leading><UiIcon name="bell" :size="13" /></template>
Notify customers
</UiButton>
<UiButton variant="primary">Mark resolved</UiButton>
</footer>
</div>
</div>
</Teleport>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 180;
}
.modal {
width: 100%;
max-width: 680px;
max-height: 86vh;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid var(--border);
}
h2 { margin: 4px 0 0 0; font-family: var(--font-display); font-weight: 600; font-size: 18px; }
.x {
width: 26px; height: 26px;
border: 0; border-radius: 6px; background: transparent;
color: var(--text-mute); cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.x:hover { background: var(--surface); color: var(--text); }
.body {
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.timeline { display: flex; flex-direction: column; gap: 14px; padding-left: 14px; border-left: 1px solid var(--border); margin-left: 6px; }
.upd { position: relative; }
.bullet {
position: absolute;
left: -19px; top: 5px;
width: 8px; height: 8px;
border-radius: 999px;
background: var(--text-mute);
}
.bullet.first { background: var(--accent); }
.upd-head { display: flex; align-items: baseline; gap: 8px; }
.who { font-size: 12px; font-weight: 500; }
.upd p { margin: 4px 0 0 0; font-size: 13px; color: var(--text-dim); }
.compose {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.compose input {
height: 32px;
background: transparent;
border: 0;
outline: 0;
font-family: inherit;
font-size: 13px;
color: var(--text);
}
.compose-foot { display: flex; justify-content: space-between; align-items: center; }
footer {
padding: 12px 20px;
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--border);
background: var(--surface);
}
</style>
+4 -11
View File
@@ -1,12 +1,9 @@
<script setup lang="ts">
type Env = 'prod' | 'staging' | 'dev'
withDefaults(
defineProps<{ env?: Env; oncall?: boolean }>(),
{ env: 'prod', oncall: true },
)
withDefaults(defineProps<{ oncall?: boolean }>(), { oncall: true })
const { user } = useOidcAuth()
const { state: tweaks } = useTweaks()
const { open: openPalette } = useCommandPalette()
const ENVS = {
prod: { label: 'PROD', fg: 'var(--text)', bg: 'rgba(244,243,238,0.08)', border: 'rgba(244,243,238,0.15)' },
@@ -14,11 +11,7 @@ const ENVS = {
dev: { label: 'DEV', fg: '#D4AAFF', bg: 'rgba(159,98,212,0.18)', border: 'rgba(159,98,212,0.36)' },
} as const
function openPalette() {
// ⌘K palette lands in O.8 — this is the trigger surface.
// eslint-disable-next-line no-console
console.log('[palette] open requested')
}
const env = computed(() => tweaks.value.env)
</script>
<template>
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
// Floating cosmetic-tweaks panel. Lives in the bottom-right corner. Lets the
// operator flip theme/density/env without touching settings pages. All three
// are pure-cosmetic — env in particular is just a colored chip in the topbar,
// not a real environment switch.
const { state, setTheme, setDensity, setEnv } = useTweaks()
const open = ref(false)
</script>
<template>
<div class="tweaks-root">
<button class="trigger" :class="{ on: open }" type="button" :title="open ? 'Close tweaks' : 'Open tweaks'" @click="open = !open">
<UiIcon :name="open ? 'x' : 'shield'" :size="14" />
</button>
<Transition name="tweaks">
<div v-if="open" class="panel" role="dialog" aria-label="Cosmetic tweaks">
<header>
<Eyebrow>Tweaks</Eyebrow>
<button class="x" type="button" aria-label="Close" @click="open = false">
<UiIcon name="x" :size="11" />
</button>
</header>
<section>
<label class="row-label">Theme</label>
<div class="seg">
<button :class="{ on: state.theme === 'dark' }" type="button" @click="setTheme('dark')">Dark</button>
<button :class="{ on: state.theme === 'light' }" type="button" @click="setTheme('light')">Light</button>
</div>
</section>
<section>
<label class="row-label">Density</label>
<div class="seg">
<button :class="{ on: state.density === 'comfy' }" type="button" @click="setDensity('comfy')">Comfy</button>
<button :class="{ on: state.density === 'compact' }" type="button" @click="setDensity('compact')">Compact</button>
</div>
</section>
<section>
<label class="row-label">Env badge</label>
<div class="seg three">
<button :class="{ on: state.env === 'prod' }" type="button" @click="setEnv('prod')">PROD</button>
<button :class="{ on: state.env === 'staging' }" type="button" @click="setEnv('staging')">STAGING</button>
<button :class="{ on: state.env === 'dev' }" type="button" @click="setEnv('dev')">DEV</button>
</div>
</section>
<footer>
<Mono dim>// cosmetic only — saved to localStorage</Mono>
</footer>
</div>
</Transition>
</div>
</template>
<style scoped>
.tweaks-root {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 50;
}
.trigger {
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
.trigger:hover { color: var(--text); border-color: var(--border-hi); }
.trigger.on { background: var(--text); color: var(--bg); border-color: var(--text); }
.panel {
position: absolute;
right: 0;
bottom: 44px;
width: 260px;
background: var(--elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.32);
}
header { display: flex; justify-content: space-between; align-items: center; }
.x {
width: 22px;
height: 22px;
border: 0;
background: transparent;
color: var(--text-mute);
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.x:hover { background: var(--surface); color: var(--text); }
section { display: flex; flex-direction: column; gap: 6px; }
.row-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-mute);
font-weight: 500;
}
.seg {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 3px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
}
.seg.three { grid-template-columns: 1fr 1fr 1fr; }
.seg button {
appearance: none;
border: 0;
background: transparent;
color: var(--text-dim);
font-family: inherit;
font-size: 12px;
font-weight: 500;
padding: 6px 8px;
border-radius: 5px;
cursor: pointer;
}
.seg button:hover { color: var(--text); }
.seg button.on { background: var(--text); color: var(--bg); }
footer { padding-top: 4px; border-top: 1px dashed var(--border); }
.tweaks-enter-active, .tweaks-leave-active { transition: opacity 0.12s, transform 0.12s; }
.tweaks-enter-from, .tweaks-leave-to { opacity: 0; transform: translateY(4px); }
</style>
@@ -0,0 +1,19 @@
// Shared open/close state for the ⌘K command palette. The trigger lives on
// the topbar and on the global keyboard shortcut handler, while the rendered
// panel lives in the default layout — they all read/write the same ref via
// this composable.
const isOpen = ref(false)
export const useCommandPalette = () => ({
isOpen,
open: () => {
isOpen.value = true
},
close: () => {
isOpen.value = false
},
toggle: () => {
isOpen.value = !isOpen.value
},
})
@@ -0,0 +1,34 @@
// Visual-only impersonation state. Real impersonation requires an OAuth
// on-behalf-of grant or admin-impersonation token endpoint; this composable
// just tracks whether the modal is open + which tenant we're "viewing" so the
// red banner and topbar warning surface render correctly. Tracked as a
// follow-up in OPERATOR-PLAN.md.
import type { Tenant } from '~/types/tenant'
const candidate = ref<Tenant | null>(null) // shown in the confirm modal
const active = ref<Tenant | null>(null) // shown in the persistent banner
const asUser = ref<string>('first-user@example.com')
export const useImpersonation = () => ({
candidate,
active,
asUser,
open: (tenant: Tenant) => {
candidate.value = tenant
},
cancel: () => {
candidate.value = null
},
confirm: (reason: string, userLabel?: string) => {
if (!candidate.value) return
active.value = candidate.value
asUser.value = userLabel ?? asUser.value
candidate.value = null
// eslint-disable-next-line no-console
console.log('[impersonation] entered', { tenant: active.value.slug, reason, asUser: asUser.value })
},
exit: () => {
active.value = null
},
})
@@ -0,0 +1,11 @@
const isOpen = ref(false)
export const useIncidentModal = () => ({
isOpen,
open: () => {
isOpen.value = true
},
close: () => {
isOpen.value = false
},
})
+69
View File
@@ -0,0 +1,69 @@
// Cosmetic tweaks for the operator shell — theme (dark/light), density
// (comfy/compact), env badge (prod/staging/dev). Persisted in localStorage so
// the choices survive page reloads. The values are applied to <html> as
// data-* attributes; tokens.css picks them up via selector overrides.
export type ThemeMode = 'dark' | 'light'
export type Density = 'comfy' | 'compact'
export type Env = 'prod' | 'staging' | 'dev'
interface TweakState {
theme: ThemeMode
density: Density
env: Env
}
const STORAGE_KEY = 'dezky-operator-tweaks'
const DEFAULTS: TweakState = { theme: 'dark', density: 'comfy', env: 'dev' }
const state = ref<TweakState>({ ...DEFAULTS })
const hydrated = ref(false)
function apply() {
if (!import.meta.client) return
const root = document.documentElement
root.setAttribute('data-theme', state.value.theme)
root.setAttribute('data-density', state.value.density)
}
function persist() {
if (!import.meta.client) return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.value))
} catch {
// localStorage can throw in private mode; tweaks are cosmetic so swallow.
}
}
function hydrate() {
if (!import.meta.client || hydrated.value) return
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<TweakState>
state.value = { ...DEFAULTS, ...parsed }
}
} catch {
// ignore corrupt JSON
}
apply()
hydrated.value = true
}
export const useTweaks = () => {
if (import.meta.client) hydrate()
function set<K extends keyof TweakState>(key: K, value: TweakState[K]) {
state.value = { ...state.value, [key]: value }
apply()
persist()
}
return {
state,
setTheme: (v: ThemeMode) => set('theme', v),
setDensity: (v: Density) => set('density', v),
setEnv: (v: Env) => set('env', v),
}
}
+17 -1
View File
@@ -5,6 +5,9 @@
const route = useRoute()
const { toggle } = useSidebar()
const { toggle: togglePalette } = useCommandPalette()
// Touch useTweaks so the persisted theme/density hydrate on first paint.
useTweaks()
// Derive the active nav row from the route. Matches OpSidebar's `id` keys.
const currentNav = computed(() => {
@@ -24,13 +27,17 @@ const currentNav = computed(() => {
return ''
})
// Keyboard shortcut: ⌘[ toggles sidebar. ⌘K palette lands in O.8.
// Keyboard shortcuts: ⌘[ toggles sidebar, ⌘K opens the command palette.
onMounted(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === '[') {
e.preventDefault()
toggle()
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
togglePalette()
}
}
document.addEventListener('keydown', onKey)
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
@@ -39,6 +46,8 @@ onMounted(() => {
<template>
<div class="shell">
<ImpersonationBanner />
<div class="cols">
<OpSidebar :current="currentNav" />
<main>
<OpTopbar />
@@ -47,15 +56,22 @@ onMounted(() => {
</div>
</main>
</div>
<CommandPalette />
<ImpersonationModal />
<IncidentModal />
<TweaksPanel />
</div>
</template>
<style scoped>
.shell {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg);
color: var(--text);
}
.cols { display: flex; flex: 1; min-height: 0; }
main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.content { flex: 1; min-width: 0; overflow-y: auto; }
+3 -1
View File
@@ -8,6 +8,8 @@ const { data: tenants, pending: tp, refresh: rT } = await useFetch<Tenant[]>('/a
const { data: partners, pending: pp, refresh: rP } = await useFetch<Partner[]>('/api/partners', { default: () => [] })
const { data: users, pending: up, refresh: rU } = await useFetch<PlatformUser[]>('/api/users', { default: () => [] })
const { open: openIncident } = useIncidentModal()
const pending = computed(() => tp.value || pp.value || up.value)
async function refresh() {
@@ -63,7 +65,7 @@ function fmtDate(d: string) {
</PageHeader>
<div class="stage">
<button v-if="incidentActive" class="incident" type="button">
<button v-if="incidentActive" class="incident" type="button" @click="openIncident">
<span class="pill">
<span class="dot" />
{{ INCIDENT.severity }} · ACTIVE
+2 -1
View File
@@ -3,6 +3,7 @@ import { SERVICES, INCIDENT, type PlatformService } from '~/data/fixtures'
const degradedCount = computed(() => SERVICES.filter((s) => s.status !== 'ok').length)
const incidentActive = computed(() => degradedCount.value > 0)
const { open: openIncident } = useIncidentModal()
function tone(s: PlatformService): 'ok' | 'warn' | 'bad' {
return s.status
@@ -41,7 +42,7 @@ function label(s: PlatformService) {
<div class="title">{{ INCIDENT.title }}</div>
<div class="sub">Started {{ INCIDENT.started }} · IC: {{ INCIDENT.ic }}</div>
</div>
<UiButton variant="primary" disabled>Open incident</UiButton>
<UiButton variant="primary" @click="openIncident">Open incident</UiButton>
</div>
<div class="grid">
+6
View File
@@ -10,6 +10,8 @@ const { data: tenant, refresh: refreshTenant } = await useFetch<Tenant>(() => `/
const activeTab = ref<'overview' | 'users' | 'resources' | 'billing' | 'audit' | 'support' | 'danger'>('overview')
const impersonate = useImpersonation()
// Lazy-fetch users only when the tab is opened.
const { data: users, refresh: refreshUsers } = useLazyFetch<TenantUser[]>(
() => `/api/tenants/${slug.value}/users`,
@@ -97,6 +99,10 @@ async function reconcile() {
<template #actions>
<Badge :tone="STATUS_TONE[tenant.status]" dot>{{ tenant.status }}</Badge>
<Badge tone="neutral">{{ tenant.plan }}</Badge>
<UiButton variant="secondary" @click="impersonate.open(tenant)">
<template #leading><UiIcon name="key" :size="13" /></template>
Impersonate
</UiButton>
<UiButton variant="secondary">
<template #leading><UiIcon name="external" :size="13" /></template>
Open workspace
+18 -9
View File
@@ -454,16 +454,25 @@ forward as bearer to platform-api.
`components/OpPlaceholder.vue`, `server/api/users/index.get.ts`,
`types/user.ts`.
### O.8 · Interactions
### O.8 · Interactions
- [ ] `CommandPalette.vue` — ⌘K opens, fuzzy search over tenants + partners
+ flags + nav items + actions
- [ ] `ImpersonationModal.vue` — visual stub with reason field, Demo-only
badge, no-op confirm + toast
- [ ] `ImpersonationBanner.vue` — top banner shown when impersonating
- [ ] `IncidentModal.vue` — mock incident render
- [ ] `TweaksPanel.vue` — theme (light/dark), density (comfy/compact),
env (prod/staging/dev cosmetic switch)
- [x] `CommandPalette.vue` + `useCommandPalette` — ⌘K opens, searches real
tenants/partners + mock flags + nav items + actions. Arrow keys + Enter
navigate, Escape/backdrop close.
- [x] `ImpersonationModal.vue` + `useImpersonation` — confirm modal with
reason field, opens from tenant detail (`Impersonate` action) and from
the palette (`Impersonate user…` action). Stub — no real OBO token is
minted. Follow-up to wire OAuth Token Exchange remains.
- [x] `ImpersonationBanner.vue` — full-width red banner at the top of the
shell, persists until `Exit impersonation` is clicked.
- [x] `IncidentModal.vue` + `useIncidentModal` — opens from the Overview and
Infrastructure incident banners, renders mock `INCIDENT` data
(metrics + timeline + draft composer).
- [x] `TweaksPanel.vue` + `useTweaks` — floating bottom-right panel. Theme
(dark/light), density (comfy/compact), env badge (prod/staging/dev).
Choices persist to localStorage, apply via `[data-theme]` / `[data-density]`
overrides in tokens.css.
- [x] Layout wires ⌘K + ⌘[ globally. Topbar reads env from `useTweaks`.
### O.9 · Verification