KERV Design System
All UI components are defined once in js/ui-kit.js
as functions on the global UI object.
Use them in every page/module — never write inline styles from scratch.
Design Tokens
CSS custom properties defined in
css/style.css. Use them everywhere — never hardcode colour values.Colour palette
Status Colors full (text/icon) · light (chip background)
Driver Colors 5 canonical drivers · chip style · UI.driverChip(name) · UI.DRIVER_OPTS
Chart Colors — 24 canonical pairs UI.CHART_COLORS_FULL (vivid) · UI.CHART_COLORS_LIGHT (tint) · index-matched
// Full — lines, bars, donut segments
new Chart(ctx, { data: { datasets: [{ backgroundColor: UI.CHART_COLORS_FULL }] } })
UI.CHART_COLORS_FULL // → ['#F47843', '#F4A234', …] (24 hex)
// Light — fills, area charts, badge backgrounds
UI.CHART_COLORS_LIGHT // → ['#FDE4D4', '#FDEACC', …] (24 hex, index-matched)
// Pair: CHART_COLORS_FULL[i] strokes, CHART_COLORS_LIGHT[i] fills the same series
Typography tokens (UI object)
| UI.IF | Base style string for input / select / textarea |
| UI.LB | Label style — 10px, uppercase, muted, 0.5px tracking |
Buttons
Five variants + two icon variants. Always use these — never write button styles inline.
Primary · Cancel · Danger
UI.btnPrimary('Save', 'mySaveFn()', 'my-btn-id') // filled accent
UI.btnCancel('Cancel', 'myCloseFn()') // ghost / secondary
UI.btnDanger('Delete', 'myDeleteFn()') // red outline
Secondary "Tools ▾" style — muted at rest, accent on hover
// Pair with a chevron SVG + ddPanel for dropdown trigger buttons
var CHEV = '<svg width="10" height="6"…><path d="M1 1l4 4 4-4"…/></svg>';
UI.btnSecondary('Tools ' + CHEV, "UI.ddToggle('tools-dd','tools-btn')", 'tools-btn')
Slim "Copy form link" / "+ Add Idea" style — 11px, surface bg
var LINK_ICO = '<svg width="11" height="11"…/>'; var PLUS_ICO = '<svg width="11" height="11"…/>'; UI.btnSlim(LINK_ICO + 'Copy form link', 'myFn()') UI.btnSlim(PLUS_ICO + 'Add Request / Idea', 'myFn()')
Text button no border, no bg — inline actions: "+ Add", "Clear all"
UI.btnText('+ Add', 'myAddFn()') // accent (default)
UI.btnText('+ Add', 'myAddFn()', '#ea580c') // custom color
UI.btnText('Clear all', 'myClearFn()', 'var(--muted)')
UI.btnText('View all →', 'myFn()', 'var(--accent)')
Icon button (28 × 28 borderless) · Icon button bordered (36 × 36)
// 28×28 borderless — inside table rows / drawers
UI.btnIcon('myFn()', 'Edit', '<svg…/>')
UI.btnIcon('myFn()', 'Delete', '<svg…/>', 'var(--faint)', '#E5243B', '#FFF0F0')
// 36×36 bordered — toolbar actions (Filter, Export, Settings…)
UI.btnIconBordered('myFilterFn()', 'Filter', '<svg…/>')
UI.btnIconBordered('myFn()', 'Settings', '<svg…/>', 32) // custom size
Avatar
Inline avatar with initials (or photo) + name/subtitle. Use
UI.avatar() for standalone display (cards, headers, drawers). Use UI.avatarCell() inside table rows — it renders the same circle but without the name text block.Avatar + Name
UI.avatar('Adam Shukur') // 34px default
UI.avatar('Bruna Pellegrini', null, { size: 26 }) // 26px — compact rows
UI.avatar('Sam Okafor', null, { size: 38 }) // 38px — profile headers
Avatar + Name + Subtitle
UI.avatar('Adam Shukur', 'Engineering Lead')
UI.avatar('Bruna Pellegrini', 'Product Manager', { size: 38 })
Size variants
UI.avatar('Sam Okafor', 'Designer', { size: 26 }) // table row
UI.avatar('Sam Okafor', 'Designer', { size: 34 }) // default
UI.avatar('Sam Okafor', 'Designer', { size: 42 }) // profile card
Avatar + Name + Dept chip + Subtitle chip inline with name · use UI.avatarChip()
UI.avatarChip('Aron Schatz', UI.deptChipSm('Product'), 'Principal Product Manager', { size: 26 })
UI.avatarChip('Bruna Coppolino', UI.deptChipSm('Design'), 'Head of Design', { size: 26 })
UI.avatarChip('Rich Kentopp', UI.deptChipSm('Tech'), 'Engineering Lead', { size: 26 })
// chipHtml accepts any HTML — deptChipSm, badge, statusChip, etc.
// size defaults to 26 if omitted
User Tile bordered card · avatar + name + dept chip + subtitle
UI.userTile('Aron Schatz', 'Product', 'Principal Product Manager, Ad Tags')
UI.userTile('Bruna Coppolino', 'Design', 'Head of Design')
UI.userTile('Rich Kentopp', 'Tech', 'Engineering Lead')
// With photo
UI.userTile('Sam Okafor', 'Strategy', 'VP Strategy', { imgSrc: '/img/sam.jpg' })
Badges & Status
Free-colour
badge() for custom labels, statusChip() for the 6 delivery statuses, and deptChip() for fixed-color department tags.Badge (free colour)
UI.badge('In Progress', '#6366F1', 'rgba(99,102,241,.1)')
UI.badge('Done', '#2EAD4B', 'rgba(46,173,75,.1)')
UI.badge('Blocked', '#E5243B', 'rgba(229,36,59,.08)')
UI.badge('Draft') // default (muted on subtle)
Counter Badge small numeric pill — inline in tabs, cells, labels
UI.counterBadge(2) // default — subtle bg, muted text
UI.counterBadge(12) // auto-widens for 2+ digits
UI.counterBadge(99)
// Custom colours
UI.counterBadge(3, 'rgba(99,102,241,.12)', '#6366F1') // indigo
UI.counterBadge(5, 'rgba(237,0,94,.10)', 'var(--accent)') // accent
// Returns '' for falsy values — safe to always call:
UI.counterBadge(0) // → ''
UI.counterBadge(null)// → ''
// Used automatically by UI.pills() when t.count is set:
UI.pills([
{id:'open', label:'Open', count:2},
{id:'closed', label:'Closed', count:12}
], 'open', 'myTabFn')
Status chip (delivery status — uses ds-* CSS classes)
UI.statusChip('not-started') // → Not Started (gray)
UI.statusChip('on-track') // → On Track (blue)
UI.statusChip('at-risk') // → At Risk (yellow)
UI.statusChip('delayed') // → Delayed (red)
UI.statusChip('on-hold') // → On Hold (orange)
UI.statusChip('delivered') // → Delivered (green)
// All values available as an array for building selects:
UI.STATUS_OPTS // [{val:'not-started',label:'Not Started'}, …]
Department chip fixed colour per department · UI.deptChip(name) · UI.DEPT_OPTS
UI.deptChip('Product') // amber
UI.deptChip('Tech') // indigo
UI.deptChip('Design') // violet
UI.deptChip('Marketing') // pink
UI.deptChip('Sales') // emerald
UI.deptChip('Strategy') // sky
UI.deptChip('People & Culture') // rose
UI.deptChip('Operations') // cyan
UI.deptChip('Finance') // green
// Ready-made options array for cellCustomSelect / customSelect:
UI.DEPT_OPTS // [{val:'Product', label:'Product', html: deptChip(…)}, …]
Department chip — Small 9.5px · 1px 6px padding · use inside compact components (avatarChip, userTile)
UI.deptChipSm('Product')
UI.deptChipSm('Tech')
UI.deptChipSm('Design')
// …same palette, smaller size
// Used automatically inside UI.userTile() and UI.avatarChip()
Progress Bars
Three variants for different contexts.
progressBarStatus(pct) coloured by delivery stage · progressBarFlat(pct, color) fixed colour, caller chooses · progressBarCapacity(pct) green/yellow/red by load level.Form Fields & Selects
Always wrap inputs with
UI.field() to get the uppercase label. Three select variants: custom (recommended), native (simple inline), multi-select with search.Text input
UI.field('Title', UI.input('my-title', 'text', 'e.g. Drive growth', value), true)
UI.field('Notes', UI.textarea('my-notes', 'Optional…', value, 3))
Custom Select (recommended — fully styled)
// Fully custom dropdown — matches the app's Theme / Status selectors.
// Value is always readable via: document.getElementById('my-q').value
UI.field('Theme', UI.customSelect('my-q', [
{val:'', label:'—'},
{val:'ad-fmt', label:'Ad Formats'},
{val:'ad-svc', label:'Ad Serving & Management'},
{val:'analytics',label:'Analytics & Reporting'}
], 'ad-fmt', 'myOnChangeFn'))
// onChangeFn is called as: myOnChangeFn(val) on every pick
Native Select (simple inline use only)
// Use only inside tight spaces where a custom panel would overflow.
// The chevron and appearance come from browser; styling is limited on some OSes.
UI.field('Quarter', UI.select('my-q', [
{val:'Q1',label:'Q1'}, {val:'Q2',label:'Q2'},
{val:'Q3',label:'Q3'}, {val:'Q4',label:'Q4'},
{val:'Backlog',label:'Backlog'}
], 'Q2'))
Multi-select with search (used in Initiative form · Jira modal)
// Full-width select-style trigger + panel — keep in a position:relative wrapper.
// Values: call document.querySelectorAll('#my-sc input:checked') to collect.
'<div style="position:relative">'
+ '<button id="my-sc-btn" onclick="UI.ddToggle(\'my-sc\',\'my-sc-btn\')"
style="width:100%;height:36px;padding:0 36px 0 11px;font-size:13px;font-family:inherit;
border:1px solid var(--border-md);border-radius:7px;background:var(--surface);
color:var(--text);cursor:pointer;display:flex;align-items:center;position:relative">
<span style="flex:1;text-align:left;color:var(--muted)">Select owners…</span>
<!-- chevron SVG -->
</button>'
+ UI.searchCheckboxPanel({
id: 'my-sc',
placeholder: 'Search people…',
items: members.map(function(m) {
return { val: m.id, label: m.name, checked: selected.indexOf(m.id) !== -1 };
}),
onChangeFn: 'myToggleOwnerFn' // called as myToggleOwnerFn(val, checked)
})
+ '</div>'
Jira Epic Select search + checkbox, key · title · status
// Same trigger pattern as multi-select, different panel function
'<div style="position:relative">'
+ '<button id="my-jira-btn" onclick="UI.ddToggle(\'my-jira\',\'my-jira-btn\')" …>
Select epics… + chevron SVG
</button>'
+ UI.jiraSelectPanel({
id: 'my-jira',
placeholder: 'Filter epics…',
items: epics.map(function(e) {
return {
val: e.id,
key: e.key, // 'SDT-8' — shown in accent colour
title: e.summary,
status: e.statusLabel, // 'In corso' / 'Da completare'
statusColor: e.statusColor, // CSS colour, default var(--faint)
checked: selected.indexOf(e.id) !== -1
};
}),
onChangeFn: 'myToggleEpicFn'
})
+ '</div>'
Section divider (inside drawer body)
UI.section('Module Permissions')
UI.section('Departments', '<button…>Select All</button>') // optional right slot
Section label "DESCRIPTION" style — standalone uppercase label, no border
UI.sectionLabel('Description') // → DESCRIPTION (10px, uppercase, muted)
UI.sectionLabel('Team', '12px') // custom margin-bottom
// Use for area headings in content views — NOT inside form fields
// (for form fields use UI.field() which wraps with its own label)
Filters Bar
Composable filter toolbar: search input + one or more filter dropdowns + Reset. Each dropdown shows muted text at rest and turns accent-bordered when a value is active.
Full bar search · dropdowns · reset
UI.filtersBar({
searchId: 'my-search',
searchPlaceholder: 'Search initiatives…',
onSearchFn: 'applyFilters()',
searchWidth: 200, // px, default 200
filters: [
{ id: 'f-driver', placeholder: 'All Drivers', options: driverOpts, onChangeFn: 'applyFilters' },
{ id: 'f-team', placeholder: 'All Teams', options: teamOpts, onChangeFn: 'applyFilters' },
{ id: 'f-status', placeholder: 'All Statuses',options: statusOpts, onChangeFn: 'applyFilters' },
],
resetFn: 'resetFilters()'
})
// Read values:
document.getElementById('my-search').value // search text
document.getElementById('f-driver').value // selected driver (hidden input)
// Read via data attribute (on wrapper):
document.getElementById('f-driver-wrap').dataset.value
Single dropdown UI.filterDd — use standalone in tight spaces
UI.filterDd('f-status', 'All Statuses', [
{ val: 'not-started', label: 'Not Started' },
{ val: 'on-track', label: 'On Track' },
{ val: 'at-risk', label: 'At Risk' },
{ val: 'delivered', label: 'Delivered' },
], 'applyFilters')
// Reset a single dropdown programmatically:
UI._fdPick('f-status', '', 'All Statuses')
Checkbox
Styled checkbox using the accent color. Three states: unchecked, checked, disabled. Always uses
accent-color:var(--accent) so it matches the brand.States
UI.checkbox('my-chk', 'Enable feature')
UI.checkbox('my-chk-on', 'Already active', true)
UI.checkbox('my-chk-dis', 'Not available', false, null, true)
UI.checkbox('my-chk-dis-on', 'Locked on', true, null, true)
// Read value:
document.getElementById('my-chk').checked // → boolean
// With change handler:
UI.checkbox('my-chk', 'Notify me', false, 'handleToggle(this.checked)')
Permission Table
Access matrix with checkboxes (enable/disable module) and radio buttons (permission level). Section headers use
var(--bg); sub-rows are indented and auto-disabled when the parent is unchecked.Example — modules with View / Edit columns
UI.permissionTable(
[
{
label: 'Product & Tech',
rows: [
{
id: 'roadmap',
label: 'Product Roadmap',
checked: true,
radioValue: '2', // '1' = View, '2' = Edit
sub: [
{ id:'roadmap-asmp', label:'Assumptions', checked:false, radioValue:'' }
]
},
{ id:'capacity', label:'Team Capacity', checked:false, radioValue:'' },
{ id:'sprint', label:'XTS Team (Sprint)', checked:false, radioValue:'' },
{ id:'ideas', label:'Product Req / Ideas',checked:false, radioValue:'' },
]
}
],
['View', 'Edit'] // radio column headers — length = number of radio columns
)
// Handlers wired via onCheckFn / onRadioFn on each row:
{ id:'roadmap', label:'Product Roadmap', checked:true,
onCheckFn: 'auToggleModule(this.checked,\'roadmap\')',
onRadioFn: 'auSetPermission' } // called as auSetPermission(val)
Radio Button
Single-choice selector. Group multiple radios with the same
name — the browser enforces mutual exclusion. Uses accent-color:var(--accent).Group
UI.radio('r-monthly', 'period', 'Monthly', 'monthly', true)
UI.radio('r-quarterly','period', 'Quarterly','quarterly', false)
UI.radio('r-annual', 'period', 'Annual', 'annual', false)
UI.radio('r-disabled', 'period', 'Disabled', 'disabled', false, null, true)
// Read selected value:
var el = document.querySelector('input[name="period"]:checked');
el ? el.value : null // → 'monthly'
// With change handler:
UI.radio('r-m', 'view', 'Monthly', 'monthly', true, 'handleViewChange(this.value)')
Search Bar
Text input with a leading search icon. Accent-colored border on focus. Works standalone or inside a toolbar / table header.
Default
// Basic — full width of its container
UI.searchBar('my-search', 'Search items…')
// With live-filter callback
UI.searchBar('my-search', 'Search users…', 'filterTable(this.value)')
// With pre-filled value
UI.searchBar('my-search', 'Search…', 'filterTable(this.value)', 'acme')
// Read value:
document.getElementById('my-search').value
Tooltip
Dark hover popovers and the "How it works" formula panel. Use
UI.tooltip(trigger, text) for any icon, label, or button — it wraps the element and shows a floating label on hover. Use UI.formulaCard() for inline formula explanations paired with a trigger link.Hover tooltip — all positions dark bg · 11px · wraps any trigger
// Round info icon — thicker strokes, no button outline
var INFO_ICO = '<span style="display:inline-flex;align-items:center;justify-content:center;'
+ 'width:22px;height:22px;color:var(--muted);cursor:default">'
+ '<svg width="15" height="15" viewBox="0 0 16 16" fill="none">'
+ '<circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="2"/>'
+ '<path d="M8 7v4.5M8 5v.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>'
+ '</svg></span>';
UI.tooltip(INFO_ICO, 'Saved automatically') // top (default)
UI.tooltip(INFO_ICO, 'Saved automatically', {pos:'bottom'})
UI.tooltip(INFO_ICO, 'Saved automatically', {pos:'right'})
UI.tooltip(INFO_ICO, 'Multi-line tooltip that wraps', {pos:'left'})
UI.tooltip(INFO_ICO, 'Long explanation here', {pos:'top', maxWidth:'140px'})
Tooltip on text / custom trigger wrap any element — icon, label, column header
// Wrap any HTML element as trigger
UI.tooltip(INFO_ICO, 'Formula explained below', {pos:'right'})
// Muted text label
UI.tooltip('<span style="font-size:12px;color:var(--muted);border-bottom:1px dashed var(--border-md);cursor:default">Weighted Score</span>', 'Value × Impact × Confidence / Effort')
Formula Card — "How it works" subtle pink bg · monospace formula boxes · optional dismiss · pair with trigger link
// Trigger link (ⓘ How it works) — toggles the panel
'<button onclick="document.getElementById(\'fc\').style.display = …"
style="color:var(--accent);…">ⓘ How it works</button>'
// Panel
UI.formulaCard(
'Enhancement Attribution Model',
'Attributes a share of existing product revenue to this initiative based on its estimated contribution to growth.',
[
'Added Value = Product Revenue (last year) × Contribution to Growth%',
'ROI = (Added Value − Project Cost) / Project Cost'
],
'myCloseFn()' // optional — omit to hide the × button
)
Breadcrumb & Page Header
Three variants of page header.
pageHeader() static · pageHeaderEditable() pencil renames inline · pageHeaderModal() pencil opens drawer. All share the same breadcrumb + h1 + right slot + subtitle structure.Breadcrumb
// Items array — last item is current page (no link, muted text)
UI.breadcrumb([
{label:'Home', href:'/'},
{label:'Product Roadmap', href:'/roadmap'},
{label:'Q2 2026'} // current page
])
// With onclick handlers (no href)
UI.breadcrumb([
{label:'Settings', onclick:'openSettings()'},
{label:'Team Members'}
])
Page Header breadcrumb + title + right slot + subtitle
UI.pageHeader({
breadcrumb: [{label:'Product'}, {label:'Product Roadmap'}],
title: 'Product Roadmap',
titleRight: UI.yearNav(2026, [2024,2025,2026,2027], 'myChangeYearFn'),
subtitle: 'Quarterly initiatives and progress status'
})
// Minimal (title only)
UI.pageHeader({ title: 'Team Members' })
Page Header · inline edit pencil next to h1 · click to rename · Enter or ✓ to save · Esc to cancel
UI.pageHeaderEditable({
id: 'my-ph',
breadcrumb: [{label:'Settings'}, {label:'Team Capacity'}],
title: 'Ads / Radius',
subtitle: 'Sprint velocity and capacity planning',
titleRight: UI.yearNav(2026, [2025,2026,2027], 'myChangeYearFn'),
onSaveFn: 'myRenameFn' // called as myRenameFn(newTitle) on save
})
// Helpers also available for external triggers:
UI._cheEdit('my-ph') // → switch to edit mode
UI._cheSave('my-ph') // → confirm
UI._cheCancel('my-ph') // → discard
Page Header · modal edit pencil calls onEditFn — opens a drawer or modal with full edit form
UI.pageHeaderModal({
breadcrumb: [{label:'Product'}, {label:'Product Roadmap'}],
title: 'Analytics dashboard 2.0',
subtitle: 'Q2 2026 · At Risk',
titleRight: UI.yearNav(2026, [2025,2026,2027], 'myChangeYearFn'),
onEditFn: 'myOpenEditDrawer()' // opens drawer with full edit form
})
function myOpenEditDrawer() {
UI.openDrawer({ id: 'edit-drawer', title: 'Edit Initiative', … });
}
Cards
Four card variants.
card() plain surface · cardStat() KPI/progress · cardHeader() titled header bar · cardCollapsable() animated toggle.Card — normal plain surface wrapper with border
UI.card({
bodyHtml: '<p style="font-size:13px;color:var(--muted)">Any content here.</p>',
padding: '20px 24px' // default
})
Card — stat / KPI large figure + badge + stacked bar + dot list
UI.cardStat({
value: 35,
label: 'Total initiatives in Q2 2026',
badge: UI.badge('Q2 2026', '#6366F1', 'rgba(99,102,241,.1)'),
bar: [
{color:'#2EAD4B', pct:11, label:'Delivered'},
{color:'#0EA5E9', pct:14, label:'On Track'},
{color:'#E5A100', pct:5, label:'At Risk'},
{color:'#E5243B', pct:3, label:'Delayed'},
{color:'#F97316', pct:2, label:'On Hold'}
],
stats: [
{color:'#2EAD4B', label:'Delivered', value:11},
{color:'#0EA5E9', label:'On Track', value:14},
{color:'#E5A100', label:'At Risk', value: 5},
{color:'#E5243B', label:'Delayed', value: 3},
{color:'#F97316', label:'On Hold', value: 2}
]
})
Card — with header same look as collapsable, always open, no chevron
UI.cardHeader({
title: 'Team Members',
subtitle: '· 5 people',
headerRight: UI.btnSlim('+ Add member', 'myAddFn()'),
bodyHtml: '…table or content…',
padding: '0' // often no padding when body is a table
})
Card — collapsable animated chevron toggle, hover bg
UI.cardCollapsable({
id: 'analysis-q2',
title: 'Analysis Q2 2026',
subtitle: '· 35 initiatives',
bodyHtml: '<p style="font-size:13px;color:var(--muted)">Analysis content here…</p>',
defaultOpen: true
})
// Collapsed by default
UI.cardCollapsable({
id: 'notes-card',
title: 'Notes',
bodyHtml:'<p style="font-size:13px;color:var(--muted)">…</p>'
})
Dropdowns
Action dropdown panel attached to a button trigger. Uses
.tb-admin-dd from style.css.Action dropdown (e.g. "Add New ▾")
// Use btnSecondary as the trigger — same style as all dropdown buttons
'<div style="position:relative">'
+ UI.btnSecondary('Add New ' + SVG_CHEV, 'UI.ddToggle(\'my-dd\',\'my-btn\')', 'my-btn')
+ UI.ddPanel('my-dd', [
{label:'Add Objective', onclick:'myAddObjFn()', svgHtml:'<svg…/>'},
'divider',
{label:'Add Key Result', onclick:'myAddKrFn()'}
])
+ '</div>'
Drawers
Right-side slide-in panel — for detailed forms, multi-step flows, complex editing. Opens with
UI.openDrawer(), closes with UI.closeDrawer(id). Footer has left (Delete) and right (Cancel + Save) slots. For shorter confirmations and quick edits use Modals instead.Example — opens a live drawer
function myOpenDrawer() {
UI.openDrawer({
id: 'my-drawer',
width: '520px',
title: 'Edit Objective',
subtitle: 'Set title, description and departments',
closeFn: 'myCloseDrawer',
bodyHtml:
UI.field('Title', UI.input('obj-title','text','e.g. Drive growth', title), true) +
UI.field('Description', UI.textarea('obj-desc','Optional…', desc, 3)) +
UI.section('Departments'),
footerLeft: UI.btnDanger('Delete', 'myDeleteFn()'),
footerRight: UI.btnCancel('Cancel','myCloseDrawer()') + UI.btnPrimary('Save','mySaveFn()','save-btn')
});
}
function myCloseDrawer() { UI.closeDrawer('my-drawer'); }
Modals
Centered dialog — for confirmations, quick forms, short edits. Scales in from center, backdrop click closes. Same API as
openDrawer() but rendered as a floating box. Use Drawers for complex multi-field forms.Example — opens a live modal
function myOpenModal() {
UI.openModal({
id: 'my-modal',
width: '480px', // default — narrower than drawer
title: 'Edit Initiative',
subtitle: 'Update status, owner and quarter',
closeFn: 'myCloseModal',
bodyHtml:
UI.field('Title', UI.input('m-title','text','Initiative name', title), true) +
UI.field('Status', UI.customSelect('m-status', UI.STATUS_OPTS, status)) +
UI.field('Owner', UI.customSelect('m-owner', ownerOpts, owner)) +
UI.field('Quarter', UI.customSelect('m-q', quarterOpts, quarter)),
footerLeft: UI.btnDanger('Delete', 'myDeleteFn()'),
footerRight: UI.btnCancel('Cancel','myCloseModal()') + UI.btnPrimary('Save','mySaveFn()','m-save')
});
}
function myCloseModal() { UI.closeModal('my-modal'); }
// Drawer vs Modal cheatsheet:
// Drawer → detailed form, many fields, side panel, stays open while browsing
// Modal → quick edit, confirmation, ≤ 6 fields, must resolve before continuing
Multi-step modal — with sidebar stepper split layout · UI.stepperV() · 2-col form grid · status pill row
UI.openModal({
id: 'add-initiative-modal',
width: '860px',
title: 'Add Initiative',
closeFn: 'closeAddInitiative',
bodyHtml:
// Split layout: negate modal padding, split left/right
'<div style="display:flex;margin:-20px -24px;min-height:460px">'
// Left — stepper sidebar
+ '<div style="width:240px;flex-shrink:0;padding:28px 20px;background:var(--bg);border-right:1px solid var(--border)">'
+ UI.stepperV(steps.map(function(s,i){ return {label:s.label, status: i<active?'done':i===active?'active':'pending'}; }))
+ '</div>'
// Right — step content
+ '<div style="flex:1;padding:28px 24px;overflow-y:auto">'
+ '<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">'
+ UI.field('Quarter', UI.customSelect('ai-q', quarterOpts, 'Q2')) + …
+ '</div>'
+ '</div>'
+ '</div>',
footerRight: UI.btnCancel('Cancel','closeAddInitiative()') + UI.btnPrimary('Next →','nextStep()','ai-next')
});
Alerts
Inline feedback banners — for form validation, async results, and contextual notices. Four semantic variants via
UI.alertBanner(type, title, message, closeFn?).Full — icon · title · message
UI.alertBanner('error', 'Something went wrong', 'The request failed. Check your input and try again.')
UI.alertBanner('success', 'Changes saved', 'Your initiative has been updated successfully.')
UI.alertBanner('info', 'Heads up', 'This action will affect all team members in the selected quarter.')
UI.alertBanner('warning', 'Unsaved changes', 'You have unsaved edits. Review before leaving this page.')
Icon + text — no title pass empty string as title
UI.alertBanner('error', '', 'Something went wrong — check your input and try again.')
UI.alertBanner('success', '', 'Changes saved successfully.')
UI.alertBanner('info', '', 'This action will affect all team members.')
UI.alertBanner('warning', '', 'You have unsaved changes.')
Text only — no icon pass empty title + noIcon:true
UI.alertBanner('error', '', 'Something went wrong — check your input and try again.', null, true)
UI.alertBanner('success', '', 'Changes saved successfully.', null, true)
UI.alertBanner('info', '', 'This action will affect all team members.', null, true)
UI.alertBanner('warning', '', 'You have unsaved changes.', null, true)
AI Insights
Contextual summary banner with a subtle pink bg + left accent border. Items render in a responsive grid — up to 5 columns, collapses automatically on smaller containers. Use
UI.aiInsights(items, opts) in any page header or section.Default — 3-column grid
UI.aiInsights([
{ icon: '⚠️', html: 'Delivery at <strong>6%</strong> — 33 initiatives still to land this quarter.' },
{ icon: '🗂️', html: '<strong>1 initiative at risk</strong> — needs immediate attention.' },
{ icon: '👥', html: '<strong>Content / APIs</strong> is the most active team this quarter with 17 initiatives.' },
{ icon: '💡', html: '<strong>Design</strong> has capacity headroom — consider pulling in more work.' },
], { quarter: 'Q2 2026' })
No quarter label · custom minColWidth
UI.aiInsights([
{ icon: '✅', text: 'All OKRs on track this quarter.' },
{ icon: '📈', text: 'Revenue up 12% month-over-month.' },
], { minColWidth: 280 }) // wider columns → fewer cols on small screens
Tables
Ghost cell components are invisible at rest and reveal their border on hover / focus. Five live table demos show how the components compose: Generic (no frozen cols, all ghost variants · like User Management editable), Read Only (
UI.trReadOnly + UI.cellReadOnly · identical structure to Generic but no edit affordances), Product Roadmap (first 2 cols sticky, full ghost set), Team Capacity (outlined inputs with FTE helper, quarterly columns, read-only computed rows), and Settings Table + Add Row (filterable header, ghost rows, solid new-row, pink Add button).Ghost Cell Components transparent at rest · border-md on hover · accent ring on focus / open
// Text input — saves on blur
UI.cellInput('my-id', value, 'Placeholder', 'mySaveFn()')
// Custom select — simple lists (Quarter, Role…) delegates to cellCustomSelect
UI.cellSelect('my-id', ['Q1','Q2','Q3','Q4','Backlog'], 'Q2')
// Custom · Status Chip
UI.cellCustomSelect('my-id', UI.STATUS_OPTS.map(function(o) {
return { val: o.val, label: o.label, html: UI.statusChip(o.val) }
}), 'on-track')
// Custom · Avatar + Name (owner columns)
UI.cellCustomSelect('my-id', owners.map(function(n) {
var ini = n.split(' ').map(function(w){return w[0]}).join('');
var bg = UI._avatarColor(n);
return { val: n, label: n,
html: '<span style="display:inline-flex;align-items:center;gap:6px">'
+ '<span style="...avatar circle...">' + ini + '</span>' + n + '</span>' }
}), owner)
// Custom · Badge (role / team columns)
UI.cellCustomSelect('my-id', teams.map(function(t) {
return { val: t.val, label: t.label, html: UI.badge(t.label, t.color, t.bg) }
}), team)
// ROI Button — empty state → "Calculate est. ROI" dashed button
UI.cellRoi(null, 'myCalcFn()')
// ROI Button — calculated state → coloured value + recalculate icon
UI.cellRoi('+45%', 'myCalcFn()')
UI.cellRoi('-12%', 'myCalcFn()')
// ── Read-only cell — static counterpart to all ghost variants ────────────
// Same 2px 5px padding + 11px font as ghost cells at rest. No border, no
// interaction. Use when a column in an editable table must be non-editable.
// Pass any HTML — plain text, UI.badge(), UI.deptChip(), avatar chip, etc.
// null / undefined / '' → renders an em-dash in var(--faint).
UI.cellReadOnly('Plain text value')
UI.cellReadOnly(UI.badge('On Track', '#2EAD4B', 'rgba(46,173,75,.09)'))
UI.cellReadOnly(UI.deptChip('Product'))
UI.cellReadOnly(null) // → —
Outlined Cell Input & Select always-visible border · accent ring on focus · use together in the same row
// Text input — saves on blur
UI.cellOutlinedInput('my-id', value, 'Placeholder', 'mySaveFn()')
// With right-side helper label
UI.cellOutlinedInput('my-id', value, '0', 'mySaveFn()', 'FTE')
UI.cellOutlinedInput('my-id', value, '0', 'mySaveFn()', '$')
UI.cellOutlinedInput('my-id', value, '0', 'mySaveFn()', '%')
// Dropdown — same outlined border, use _cosPick() to restore saved value
UI.cellOutlinedSelect('my-id', [{val:'XS',label:'XS'},{val:'M',label:'M'}…], '', 'mySaveFn()')
UI._cosPick('my-id', savedVal, savedVal) // restore on load
Table View — Generic no frozen cols · all ghost cell variants · like User Management
UI.tableScroll(cols, rows.map(function(r) {
return '<tr>'
+ '<td>' + UI.cellCustomSelect('owner-'+r.id, ownerOpts, r.owner) + '</td>'
+ '<td>' + UI.cellInput('title-'+r.id, r.title, 'Initiative name', 'save('+r.id+')') + '</td>'
+ '<td>' + UI.cellSelect('role-'+r.id, roleOpts, r.role) + '</td>'
+ '<td>' + UI.cellCustomSelect('team-'+r.id, teamBadgeOpts, r.team) + '</td>'
+ '<td>' + UI.btnIcon('del('+r.id+')', 'Delete', trashSvg, …) + '</td>'
+ '</tr>';
}).join(''), 'gt-table-body')
Table View — Read Only identical to Table View Generic · no edit affordances · like User Management
// UI.trReadOnly(cells, opts?)
// Pixel-identical to tr() / User Management rows.
// cells — array of HTML strings (plain text, badges, chips, avatars…)
// opts.onclick — adds cursor:pointer + var(--subtle) row hover
// opts.style — extra inline style on <tr>
UI.table(cols,
rows.map(function(r) {
return UI.trReadOnly([
UI.avatarCell(r.name, r.email),
UI.deptChip(r.department),
'<span style="font-size:12px;color:var(--muted)">' + r.jobTitle + '</span>',
UI.badge(r.status, statusColor, statusBg),
UI.cellReadOnly(null) // empty cell → —
], { onclick: 'openDetail(' + r.id + ')' });
}).join(''),
'my-tbody')
Table View — Product Roadmap (frozen columns) first 2 cols sticky · ghost cell inputs · custom selects
UI.tableScroll(cols, rows.map(function(r) {
return '<tr>'
+ '<td>' + UI.cellCustomSelect('q-'+r.id, quarterOpts, r.quarter) + '</td>'
+ '<td>' + UI.cellInput('ttl-'+r.id, r.title, 'Initiative name', 'save('+r.id+')') + '</td>'
+ '<td>' + UI.progressCell(r.pct, r.epics) + '</td>'
+ '<td>' + UI.cellCustomSelect('drv-'+r.id, driverOpts, r.driver) + '</td>'
+ '<td>' + UI.cellCustomSelect('tm-'+r.id, teamOpts, r.team) + '</td>'
+ '<td>' + UI.cellCustomSelect('po-'+r.id, ownerOpts, r.owner) + '</td>'
+ '<td>' + UI.cellCustomSelect('th-'+r.id, themeOpts, r.theme) + '</td>'
+ '<td>' + UI.cellCustomSelect('ds-'+r.id, statusOpts, r.status) + '</td>'
+ '<td>' + UI.btnIcon('del('+r.id+')', 'Delete', trashSvg, …) + '</td>'
+ '</tr>';
}).join(''), 'rnx-table-body', 2)
Table View — Team Capacity outlined cell inputs with FTE helper · read-only computed rows · quarterly columns
// FTE editable row — one cellOutlinedInput per quarter (white bg)
function fteRow(label, disc) {
return '<tr onmouseenter="this.style.background=\'#FAFAF8\'" …>'
+ '<td style="…TDL">' + label + '</td>'
+ quarters.map(function(q) {
return '<td style="…TD">' + UI.cellOutlinedInput(disc+'-'+q, val, '0', 'save()', 'FTE') + '</td>';
}).join('') + '</tr>';
}
// Run of Business row — % helper, orange bg (#FFF7ED / #9A3412)
function robPctRow(label, disc) {
return '<tr>'
+ '<td style="…TDPl">' + label + '</td>'
+ quarters.map(function(q) {
return '<td style="…TDP">' + UI.cellOutlinedInput(disc+'-'+q, val, '0', 'save()', '%') + '</td>';
}).join('') + '</tr>';
}
// Computed read-only row — green bg (#F0FDF4 / #166534) + tooltip ⓘ
function calcRow(label, disc, formula) {
return '<tr>'
+ '<td style="…TDCl">' + label + tip(formula) + '</td>'
+ quarters.map(function(q) {
return '<td style="…TDC">' + computedDays + 'd</td>';
}).join('') + '</tr>';
}
Settings Table + Add Row filterable category header · ghost inputs on existing rows · solid inputs on add row · sticky tfoot
// Existing rows — UI ghost + outlined components
UI.cellSelect('id', CATS, row.cat) // ghost custom-dropdown (category)
UI.cellInput('id', row.name, 'Assumption name', '') // ghost text input (name)
UI.cellOutlinedInput('id', String(row.val), '0', '') // outlined input (value)
UI.cellSelect('id', UNIT_OPTS, row.unit) // ghost custom-dropdown (unit)
UI.btnIcon('', 'Delete', SVG_TRASH, …) // icon button (delete)
// Add row — solid inputs (always-visible border, background:var(--bg))
'<select style="SELs">' + catOpts + '</select>' // solid native select (category)
'<input style="INPs" />' // solid text input (name)
UI.cellOutlinedInput('id', '', '0', '') // outlined input (value)
'<select style="SELs">' + unitOpts + '</select>' // solid native select (unit)
UI.btnPrimary('Add', 'kbAsmAdd()') // primary button (save)
ROI Calculator Table
Compact two-block table (Benefits + Costs) with a summary ROI row. Section headers use
var(--bg). All editable cells use UI.cellOutlinedInput or the new UI.cellOutlinedSelect. Computed outputs are read-only spans. The "+ Add" row action uses UI.btnText.Full table — Benefits · Costs · ROI row
// Benefits block
'<tr><td colspan="2" style="background:var(--bg);…">Benefits</td></tr>'
+ '<tr><td>Additional Revenue</td><td>' + UI.cellOutlinedInput('id', '', '0', 'calc()', '$') + '</td></tr>'
// Costs block
'<tr><td colspan="4" style="background:var(--bg);…">Costs</td></tr>'
+ '<tr><td>Engineering</td><td>' + UI.cellOutlinedSelect('id', tshirtOpts, '', 'calc()') + '</td>…</tr>'
// "+ Add" in table footer
UI.btnText('+ Add', 'addRowFn()', '#ea580c')
// ROI row (below table)
'<div style="background:var(--bg);border:1px solid var(--border);border-radius:12px;…">ROI …</div>'
Outlined cell select UI.cellOutlinedSelect — permanent border, matches cellOutlinedInput
var tshirtOpts = [
{val:'', label:'—'},
{val:'XS',label:'XS'}, {val:'S',label:'S'}, {val:'M',label:'M'},
{val:'L', label:'L'}, {val:'XL',label:'XL'}
];
UI.cellOutlinedSelect('rnxroi-eng_size', tshirtOpts, '', 'rnxRoiCalc') // empty
UI.cellOutlinedSelect('rnxroi-des_size', tshirtOpts, 'M', 'rnxRoiCalc') // selected
// Read value: document.getElementById('rnxroi-eng_size').value
// Restore: UI._cosPick('rnxroi-eng_size', savedVal, savedVal)
Accordion
Stacked expandable rows inside a bordered card. Each row has a chevron, bold title, optional muted meta, and arbitrary right-side HTML (priority dot, status badge, etc.). Expanded body is indented to align past the chevron. State is managed externally — pass
open:true on the active item and a toggleFn string.Accordion list chevron · title · meta · right slot · expanded body · UI.accordion(items, {toggleFn})
var items = [
{ id: 1, title: 'Request title', meta: 'bruna · May 21, 2026', right: priorityHtml + badgeHtml, body: bodyHtml, open: false },
{ id: 2, title: 'Expanded item', meta: 'alex · May 20, 2026', right: priorityHtml + badgeHtml, body: bodyHtml, open: true },
];
UI.accordion(items, { toggleFn: 'myToggleFn' })
// myToggleFn(id) — manages open state and re-renders
Steppers
Horizontal for top-of-page progress flows; vertical for timeline-style status.
Horizontal stepper
UI.stepperH([
{label:'Discovery', sublabel:'Completed', status:'done'},
{label:'Definition', sublabel:'In progress', status:'active'},
{label:'Build', status:'pending'},
{label:'Launch', status:'pending'}
])
Vertical stepper
UI.stepperV([
{label:'Spec written', sublabel:'Mar 12', status:'done'},
{label:'Design approved', sublabel:'Mar 19', status:'done'},
{label:'In development', sublabel:'Now', status:'active'},
{label:'QA & release', status:'pending'}
])
How to use
The UI Kit is a single JS file loaded before all other modules.
1.
2. Every new drawer, form, table, or navigation component must use
3. Existing modules will be migrated progressively — one module at a time.
4. To add a new component, add it to
js/ui-kit.js is loaded first in index.html — the global UI object is available everywhere.2. Every new drawer, form, table, or navigation component must use
UI.* helpers.3. Existing modules will be migrated progressively — one module at a time.
4. To add a new component, add it to
ui-kit.js and document it here.