feat(operator): design system port + persistent shell (O.4)
Operator portal now wears its real chrome instead of placeholder spans.
Sidebar + topbar + page header all rendered against the carbon palette
from tokens.css.
Components ported from the source design (operator-app.jsx,
platform-ui.jsx, operator-screens.jsx) as Vue 3 SFCs in
apps/operator/components/:
Foundation: NodeMark (copied from portal), UiIcon (expanded to 31 icons
covering sidebar/topbar/sort/arrows)
Primitives: Card (3 surface variants), UiButton (primary / secondary /
ghost / dark / danger × sm / md / lg), DataTable (header + rows),
Badge (7 tones), Avatar (deterministic palette by name hash), Mono,
Eyebrow, StatusDot, PageHeader (with actions slot)
Shell: OpSidebar (collapsible 232<->56px, 12 nav items in 4 sections,
active-row highlight from route, badge slot, brand + user footer);
OpTopbar (env badge with prod/staging/dev variants, palette trigger
stub for the ⌘K work in O.8, on-call pill, bell, avatar)
Layouts: layouts/default.vue wires sidebar + topbar + slot; layouts/blank.vue
is used by the login page (definePageMeta layout:'blank'). app.vue now
wraps NuxtPage in NuxtLayout (the missing piece — without it Nuxt warns
"Your project has layouts but the <NuxtLayout /> component has not been
used" and renders nothing chrome-wise).
Composable composables/useSidebar.ts holds the collapsed state shared
between OpSidebar's toggle button and layouts/default.vue's ⌘[ keyboard
shortcut.
Verified in the browser:
- Sidebar renders all 12 nav links with section dividers, env badge shows
PROD, PageHeader resolves to the user's display name from
useOidcAuth().user
- Collapse toggle flips sidebar width 232↔56; nav rows become icon-only
- Smoke test on the placeholder home still returns 409 for the seeded
test-partner (token forwarding survives the layout refactor)
Gotcha documented in the plan: Vite 7.3 added a strict
server.allowedHosts check that returns plaintext 403 for any host header
that isn't the dev origin. The customer portal pre-dates this Vite
version; operator needs allowedHosts: ['operator.dezky.local'] in
nuxt.config.ts under vite.server.
Pages/index.vue replaces the bare HTML placeholder from O.3 with the
new PageHeader + Card primitives — same smoke-test functionality, much
better visual fidelity.
Real screen content (Tenants, Partners, Infrastructure, etc.) lands in
O.5+. This commit is the chrome, the smoke test, and the verification
that the design system primitives compose correctly.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
// Operator-scoped icon set. Lucide-style, single stroke, currentColor by
|
||||
// default. Expanded from the portal's set with the icons the operator UI
|
||||
// needs (sidebar, topbar, sort indicators, etc.).
|
||||
|
||||
export type IconName =
|
||||
| 'home' | 'users' | 'building' | 'briefcase' | 'help' | 'card' | 'database' | 'plug' | 'shield' | 'file'
|
||||
| 'mail' | 'calendar' | 'folder' | 'key' | 'check' | 'x' | 'plus' | 'more'
|
||||
| 'search' | 'bell' | 'logout'
|
||||
| 'chevDown' | 'chevRight' | 'chevLeft' | 'chevUpDown'
|
||||
| 'arrowUp' | 'arrowDown' | 'arrowRight'
|
||||
| 'external'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
name: IconName
|
||||
size?: number
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{
|
||||
size: 16,
|
||||
stroke: 'currentColor',
|
||||
strokeWidth: 1.6,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
:stroke="stroke"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
style="flex-shrink: 0"
|
||||
>
|
||||
<template v-if="name === 'home'"><path d="M3 11l9-8 9 8" /><path d="M5 10v10h14V10" /></template>
|
||||
<template v-else-if="name === 'users'"><circle cx="9" cy="8" r="3.5" /><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6" /><circle cx="17" cy="9" r="2.5" /><path d="M21.5 19c0-2.6-2-4.5-4.5-4.5" /></template>
|
||||
<template v-else-if="name === 'building'"><rect x="4" y="3" width="16" height="18" rx="1" /><path d="M8 7h2M14 7h2M8 11h2M14 11h2M8 15h2M14 15h2" /><path d="M10 21v-4h4v4" /></template>
|
||||
<template v-else-if="name === 'briefcase'"><rect x="3" y="7" width="18" height="13" rx="2" /><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M3 13h18" /></template>
|
||||
<template v-else-if="name === 'help'"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-1 .4-1 1.2-1 1.7" /><circle cx="12" cy="16.5" r="0.5" fill="currentColor" /></template>
|
||||
<template v-else-if="name === 'card'"><rect x="2.5" y="5.5" width="19" height="13" rx="2" /><path d="M2.5 10h19" /></template>
|
||||
<template v-else-if="name === 'database'"><ellipse cx="12" cy="5" rx="8" ry="3" /><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5" /><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" /></template>
|
||||
<template v-else-if="name === 'plug'"><path d="M9 2v6" /><path d="M15 2v6" /><rect x="6" y="8" width="12" height="6" rx="2" /><path d="M12 14v3" /><path d="M9 21h6" /></template>
|
||||
<template v-else-if="name === 'shield'"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" /></template>
|
||||
<template v-else-if="name === 'file'"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" /><path d="M14 3v6h6" /></template>
|
||||
<template v-else-if="name === 'mail'"><rect x="2.5" y="5" width="19" height="14" rx="2" /><path d="M3 7l9 6 9-6" /></template>
|
||||
<template v-else-if="name === 'calendar'"><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18" /><path d="M8 3v4" /><path d="M16 3v4" /></template>
|
||||
<template v-else-if="name === 'folder'"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></template>
|
||||
<template v-else-if="name === 'key'"><circle cx="9" cy="14" r="4" /><path d="M12.5 11L20 3.5l-2-2-2 2 2 2-2 2 2 2" /></template>
|
||||
<template v-else-if="name === 'check'"><path d="M5 12l5 5L20 7" /></template>
|
||||
<template v-else-if="name === 'x'"><path d="M6 6l12 12" /><path d="M18 6L6 18" /></template>
|
||||
<template v-else-if="name === 'plus'"><path d="M12 5v14" /><path d="M5 12h14" /></template>
|
||||
<template v-else-if="name === 'more'"><circle cx="5" cy="12" r="1.5" fill="currentColor" /><circle cx="12" cy="12" r="1.5" fill="currentColor" /><circle cx="19" cy="12" r="1.5" fill="currentColor" /></template>
|
||||
<template v-else-if="name === 'search'"><circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" /></template>
|
||||
<template v-else-if="name === 'bell'"><path d="M6 8a6 6 0 0 1 12 0c0 4 1.5 6 2 7H4c.5-1 2-3 2-7z" /><path d="M10 19a2 2 0 0 0 4 0" /></template>
|
||||
<template v-else-if="name === 'logout'"><path d="M14 4h5a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-5" /><path d="M9 12h11" /><path d="M14 8l4 4-4 4" /></template>
|
||||
<template v-else-if="name === 'chevDown'"><path d="M6 9l6 6 6-6" /></template>
|
||||
<template v-else-if="name === 'chevRight'"><path d="M9 6l6 6-6 6" /></template>
|
||||
<template v-else-if="name === 'chevLeft'"><path d="M15 6l-6 6 6 6" /></template>
|
||||
<template v-else-if="name === 'chevUpDown'"><path d="M8 10l4-4 4 4" /><path d="M8 14l4 4 4-4" /></template>
|
||||
<template v-else-if="name === 'arrowUp'"><path d="M12 19V5" /><path d="M5 12l7-7 7 7" /></template>
|
||||
<template v-else-if="name === 'arrowDown'"><path d="M12 5v14" /><path d="M19 12l-7 7-7-7" /></template>
|
||||
<template v-else-if="name === 'arrowRight'"><path d="M5 12h14" /><path d="M13 5l7 7-7 7" /></template>
|
||||
<template v-else-if="name === 'external'"><path d="M14 4h6v6" /><path d="M20 4l-9 9" /><path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5" /></template>
|
||||
</svg>
|
||||
</template>
|
||||
Reference in New Issue
Block a user