qu-notify-design-system
v1.9.0
Published
Qu Notify design system — tokens, components, and brand assets for the Qu Notify restaurant analytics app.
Maintainers
Readme
qu-notify-design-system
Design system for Qu Notify — the restaurant analytics mobile app by Qu. Contains tokens, components, fonts, icons, screenshots, and brand assets.
For AI design agents: Read constraints.md first, then screen-anatomy.md, then come back here for the API surface. Those two files are load-bearing.
What's in this repo
notify-design-system/
├── tokens.json ← W3C DTCG format. Single source of truth for all values.
├── tokens.css ← CSS custom properties. Import this in any HTML/CSS project.
├── styles.css ← Tailwind v4 entry point. One import replaces all boilerplate.
├── tailwind.config.js ← Tailwind v3 preset. Use as a preset in consuming projects.
├── constraints.md ← Hard never/always rules. Read before building any UI.
├── screen-anatomy.md ← Every screen decomposed spatially. Read before building any screen.
├── CHANGELOG.md ← Decision log — why things are the way they are.
├── components/ ← React/TypeScript components
│ ├── index.ts ← Barrel export (single import surface)
│ ├── button.tsx, input.tsx, checkbox.tsx, radio.tsx, toggle.tsx
│ ├── selector.tsx, tabs.tsx, switcher.tsx, bottom-nav.tsx
│ ├── badge.tsx, metric-tile.tsx, stat-card.tsx, status-tile.tsx
│ ├── modal.tsx, bottom-sheet.tsx, sheet-select.tsx, dropdown-select.tsx, toast.tsx, filter-bar.tsx
│ ├── empty-state.tsx, code-input.tsx, wheel-date-picker.tsx
│ ├── data-table.tsx, compare-table.tsx, trend-tag.tsx
│ ├── screen-header.tsx, section-header.tsx, drawer-section.tsx
│ ├── qu-mark.tsx, notify-lockup.tsx, icon-badge.tsx
│ ├── charts/ ← Lazy-loadable chart components (qu-notify-design-system/charts)
│ └── icons/ ← React icon components (also exported as qu-notify-design-system/icons)
├── assets/
│ ├── logo-notify-lockup.svg
│ ├── logo-qu.svg
│ ├── logo-q-mark.svg
│ └── icons/ ← SVG stroke icons, one file per icon
├── fonts/ ← Zilla Slab TTF (all weights). Inter + Red Hat Text via Google Fonts.
└── screenshots/ ← 27 production app screenshots, named descriptivelyProduct context
Qu Notify is an iOS-first mobile app used by restaurant franchisees, group managers, and store managers to monitor real-time sales, labor, and kitchen performance. It is checked multiple times daily — it is a utility, not an experience. Clarity and data density take priority over decoration.
Primary screens: Splash → Sign In → Dashboard (Sales/Labor/Store/Product tabs) → Metric detail → Menu overlay → Check Search → Tills → Kitchen Intelligence
Users: Franchisees, group managers, store managers
Data shown: Net Sales, Checks, Average Check, Payments, Gross Sales, Discounts, Cash, Tills (Open/Closed/Reconciled), Voids, Service Charges, Labor, Speed of Service, Kitchen fulfillment times
Quick start
Tailwind v4 (recommended — two lines, no config file)
/* your global CSS */
@import "tailwindcss";
@import "qu-notify-design-system/styles.css";That's it. No tailwind.config.js, no @config, no @source lines. The styles.css entry ships all tokens and tells Tailwind to scan the package's built components automatically.
Add the Google Fonts <link> to your HTML <head> (see Typography below).
Tailwind v3
// tailwind.config.js
const notifyPreset = require('qu-notify-design-system/tailwind')
module.exports = {
presets: [notifyPreset],
content: ['./src/**/*.{ts,tsx,html}'],
}/* your global CSS */
@import 'qu-notify-design-system/tokens.css';CSS custom properties only (no Tailwind)
/* In your global CSS */
@import 'qu-notify-design-system/tokens.css';React components
import { Button, MetricTile, TabBar, InputField, FilterBar, DataTable } from 'qu-notify-design-system/components'
// Dashboard tile
<MetricTile label="Net Sales" value="$345.58" trend={11.8} trendLabel="vs yesterday" />
// Primary CTA
<Button variant="primary" size="lg">Sign In</Button>
// Tab bar
<TabBar tabs={["Sales", "Labor", "Store", "Product"]} value={tab} onValueChange={setTab} />Chart components (lazy-loadable subpath)
import {
CategoryPieChart,
CompareChartLegend,
CompareLineChart,
DualAxisLineChart,
TrendLineChart,
} from 'qu-notify-design-system/charts'
<CompareChartLegend primaryLabel="Today" comparisonLabel="Previous Day" />
<CompareLineChart data={rows} xKey="hour" primaryKey="today" comparisonKey="previous" />
<CategoryPieChart data={[{ name: "Cash", value: 62.4 }, { name: "Card", value: 37.6 }]} />
<DualAxisLineChart
data={rows}
xKey="hour"
leftKey="avgSec"
rightKey="orders"
xAxisType="number"
/>
<TrendLineChart data={series} variant="dark" />Tokens (raw JSON)
const tokens = require('qu-notify-design-system/tokens')
const techBlue = tokens.primitive.color.brand['tech-blue'].$value // "#40CCF2"Inline SVG icons
<!-- Direct reference -->
<img src="node_modules/qu-notify-design-system/assets/icons/search.svg" />
<!-- Or import and inline (recommended) -->
import SearchIcon from 'qu-notify-design-system/assets/icons/search.svg'Design fundamentals
Colors (key values)
| Token | Value | Usage |
|---|---|---|
| primitive.color.brand.tech-blue | #40CCF2 | Primary CTA buttons, input focus border, context selectors, checkbox on-state, toggle on-state |
| primitive.color.brand.teal | #339FB8 | Secondary button outline/text, link text, action labels, check numbers |
| primitive.color.neutral.black | #000000 | Primary text, tab/nav selected fill |
| primitive.color.neutral.white | #FFFFFF | Card backgrounds, text on dark fills |
| primitive.color.neutral.gray-50 | #F4F4F4 | App screen background (never white at page level) |
| primitive.color.neutral.gray-100 | #DEDEDE | Tab bar container, switcher container, inactive button bg |
| primitive.color.brand.red | #EF2149 | Error borders, error text, required asterisk, toast bg |
| primitive.color.ki.cyan | #14D4EC | Kitchen Intelligence accent (scores, glass-picker radios) — not the brand cyan |
| primitive.color.ki.bg / .card / .track | #0E1419 / #1A2129 / #222B34 | KI dark-screen surfaces (mode-stable) |
Critical: #40CCF2 (cyan) is the CTA button color. #000000 (black) is the selected tab/nav color. These are separate roles and must not be swapped. The KI cyan #14D4EC is a third, screen-family-local accent — use --p-ki-cyan, never substitute the brand cyan for it (or vice versa).
Typography
| Family | Role | Usage | |---|---|---| | Inter | Primary | Body text, data labels, metric values, captions | | Red Hat Text | Secondary | Page headers, input labels, button labels, section titles | | Zilla Slab | Display | Splash screen, H2 display headings, pull quotes only |
Font loading is the consumer's responsibility. tokens.css declares font families (--font-primary, --font-display, etc.) but does not load any font files — the canonical mechanism is a single HTML <link> to Google Fonts covering all four families:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Red+Hat+Text:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=Red+Hat+Display:wght@400;500;600;700;800;900&family=Zilla+Slab:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap"
>For meta-frameworks (TanStack Start, Next, Remix, etc.) put the equivalent <link> in the root route's head(). Don't try to @import url('https://fonts.googleapis.com/...') from your CSS — Tailwind v4 / Vite inline tokens.css and strip the @import statement silently, so fonts won't load.
Zilla Slab TTF files ship at dist/fonts/ for offline / self-hosting scenarios, but tokens.css does not reference them by default. If you need to self-host, add your own @font-face declarations in your project CSS pointing at copies in your public/ directory.
Shapes
- Inputs: Always
border-radius: 9999px(full pill). No exceptions. - Buttons: Always
border-radius: 9999px(full pill). No exceptions. - Metric tiles:
border-radius: 16px - Modals:
border-radius: 24px - Bottom nav:
border-radius: 60px - Tab bar container:
border-radius: 9999px - Switcher container:
border-radius: 8px(not pill — this distinguishes it from TabBar)
Disabled states
Always opacity: 0.5 on the entire component. Never a color swap. The component's current fill/stroke colors remain intact underneath the opacity reduction.
Dark mode
Two modes ship: Light (default) and Dark. Dark is opt-in — set data-theme="dark" (or class="dark") on a root element (<html> or any wrapper):
<html data-theme="dark"> … </html>Only the semantic color aliases that change are redeclared under the dark selector in tokens.css (surfaces invert to near-black, text to white, nav-selected flips, etc.). Primitives, spacing, radius, typography, and the brand/accent/status colors are mode-stable. Components consume semantic tokens, so they re-theme automatically with no per-component work. The dark values are ported 1:1 from the Figma "Semantic" collection (Dark mode) and documented in tokens.json under $extensions.qu.modes.dark. Tailwind's dark: variant is wired to both the .dark class and [data-theme="dark"].
Component API
Button
<Button
variant="primary" // primary | secondary | tertiary | link
size="md" // xsm | sm | md | lg
state="active" // active | inactive
iconOnly={false}
disabled={false}
>
Sign In
</Button>
<IconButton variant="primary" size="md"><SearchIcon /></IconButton>InputField
<InputField
type="default" // default | password | search
label="Username"
required
placeholder="[email protected]"
state="normal" // normal | active | filled | error | disabled | readonly
errorMessage="Invalid email"
helperText="We'll never share your email"
/>Checkbox / Radio / Toggle
<Checkbox label="Remember me" defaultChecked />
<Checkbox label="Select all" indeterminate />
<RadioGroup name="period" value={val} onChange={setVal} label="Time period">
<Radio value="day" label="Day" />
<Radio value="week" label="Week" />
<Radio value="month" label="Month" />
</RadioGroup>
<Toggle label="Enable notifications" checked={on} onChange={setOn} />Selector
<Selector label="All Stores" variant="primary" state="active" open={isOpen} />
<Selector label="This Week" variant="secondary" state="active" />
<SelectorGroup>
<Selector label="StoreName" icon={<StoreIcon />} />
<Selector label="01/06/26" icon={<CalendarIcon />} />
</SelectorGroup>DropdownSelect
Anchored single-select dropdown that reuses Selector as its trigger (for
mobile/compact overlays where a full-screen SheetSelect is not desired).
import { DropdownSelect } from "qu-notify-design-system/components"
import { Sliders, Store } from "qu-notify-design-system/icons"
<DropdownSelect
icon={<Sliders size={16} />}
options={["Net Sales", "Gross Sales", "Avg Check"]}
defaultValue="Net Sales"
/>
<DropdownSelect
icon={<Store size={16} />}
options={[
"FRONT KDS - TOP",
"DRIVE EXPO KDS - TOP",
{ value: "FRONT EXPO KDS - TOP", disabled: true },
]}
placeholder="Select store"
/>DropdownSelect uses preset="default" styling by default (no extra prop
required): app-style trigger/dropdown treatment and option typography, while
keeping built-in menu padding (p-1) for row gutter.
TabBar
<TabBar
tabs={["Sales", "Labor", "Store", "Product"]}
value={activeTab}
onValueChange={setActiveTab}
/>
// With content panels:
<TabPanels value={activeTab}>
<TabPanel value="Sales"><SalesView /></TabPanel>
<TabPanel value="Labor"><LaborView /></TabPanel>
</TabPanels>Switcher
<Switcher
segments={["Day", "Week", "Month"]}
defaultValue="Week"
onValueChange={setPeriod}
/>
// With icon segments:
<Switcher segments={[{value:"list", label:"List", icon:<ListIcon/>}, ...]} />BottomNav
<BottomNavContainer>
<BottomNav
items={[
{ value: "dashboard", label: "Dashboard", icon: <DashboardIcon /> },
{ value: "inventory", label: "Inventory", icon: <BoxIcon />, badge: 3 },
{ value: "menu", label: "Menu", icon: <MenuIcon /> },
]}
value={activeRoute}
onValueChange={(v) => router.push(v)}
/>
</BottomNavContainer>BottomNavContainer positions the nav as fixed bottom-6 left-1/2 -translate-x-1/2.
MetricTile
<MetricTile
label="Net Sales"
value="$345.58"
trend={11.8}
trendLabel="vs yesterday"
/>
<MetricTileGrid cols={2}>
<MetricTile label="Net Sales" value="$345.58" trend={11.8} />
<MetricTile label="Checks" value="11" trend={18.1} />
<MetricTile label="Avg Check" value="$33.86" trend={7.7} />
<MetricTile label="Gross Sales" value="$368.40" trend={11.4} />
</MetricTileGrid>
// Loading state:
<MetricTile label="Net Sales" value="" trend={0} loading />Badge / TrendBadge
<Badge variant="success">Open</Badge>
<Badge variant="error">Closed</Badge>
<Badge variant="brand">NEW</Badge>
// Auto-picks color from sign:
<TrendBadge value={11.8} /> // → green "+11.8%"
<TrendBadge value={-5.6} /> // → red "−5.6%"Modal
Two variants: dark (default), flat.
// Dark — gradient frosted glass. Version prompts, Face ID, session expiry.
<Modal open={open} onDismiss={() => setOpen(false)}>
<ModalHeader>Enable Face ID?</ModalHeader>
<ModalBody>Use Face ID for faster sign-in on this device.</ModalBody>
<Button variant="primary" size="lg">Enable Face ID</Button>
</Modal>
// Flat — flat frosted glass. Access-gate / permission dialogs.
<Modal open={open} variant="flat" onDismiss={close}>
<ModalHeader>Access Required</ModalHeader>
<ModalBody>Contact your administrator for help.</ModalBody>
<Button>Got it</Button>
</Modal>BottomSheet
<BottomSheet open={open} onDismiss={() => setOpen(false)}>
<h2>Menu</h2>
…content…
</BottomSheet>
// Content-sized sheet, above bottom nav (z-50)
<BottomSheet open={open} autoHeight zIndex={51} onDismiss={close}>
<ConfirmCard />
</BottomSheet>Surface, handle, and scrim default to tokens (--color-bg-card, --color-text-primary,
--color-scrim). Four optional raw-CSS props skin the sheet for off-token surfaces:
// Dark sheet — menu overlay
<BottomSheet
open={open}
onDismiss={close}
heightPercent={90}
background="#1F2329"
handleColor="rgba(255,255,255,0.4)"
>
…menu items…
</BottomSheet>
// Full-screen frosted-glass picker (Day Part / Trend select)
<BottomSheet
open={open}
onDismiss={close}
heightPercent={100}
zIndex={70}
background="rgba(20, 28, 38, 0.72)"
backdropFilter="blur(28px) saturate(160%)" // also swaps shadow to glass treatment
handleColor="rgba(255,255,255,0.35)"
scrim="rgba(0,0,0,0.55)"
>
…option list…
</BottomSheet>With autoHeight, content scrolls only once the heightPercent cap is hit —
short content shows no scrollbar.
Toast
{hasError && <Toast message="Ooops, we are having problems" />}
<Toast message="Settings saved" variant="success" />
// Attached bar flush with viewport bottom (dashboard error state)
<Toast message="Could not load data" position="attached" zIndex={15} />SheetSelect
Full-screen single-select picker in a bottom sheet — the official pattern for
option pickers (replaces anchored popovers and dropdown selects). Selecting an
option fires onChange, then the sheet dismisses.
// Glass (default) — Day Part picker with subtitle lines
<SheetSelect
open={open}
onDismiss={() => setOpen(false)}
title="Select Day Part"
options={[
{ value: "Morning", subtitle: "5:00 AM – 12:00 PM" },
{ value: "Lunch", subtitle: "12:01 PM – 2:30 PM" },
]}
value={dayPart}
onChange={setDayPart}
accent="var(--p-ki-cyan)" // KI screen-family cyan; omit for brand accent
/>
// Searchable — the store-picker pattern (search input filters the list)
<SheetSelect
open={open}
onDismiss={close}
title="Select Store"
searchable
searchPlaceholder="Search Store"
options={storeNames}
value={store}
onChange={setStore}
accent="var(--p-ki-cyan)"
/>
// Light sheet for standard screens (e.g. metric picker)
<SheetSelect
open={open}
onDismiss={close}
theme="light"
heightPercent={60}
title="Select Metric"
options={["Net Sales", "Check Count", "Average Check"]}
value={metric}
onChange={setMetric}
/>EmptyState
Centered icon + message for empty lists, zero-result filters, and error screens. Fills its flex parent.
<EmptyState icon={<Store size={48} />} title="No items yet" />
<EmptyState
icon={<Bell size={48} />}
title="Couldn't load data"
description="Check your connection and try again."
action={<Button size="sm">Refresh</Button>}
/>CodeInput
One-time-code entry as single-character boxes. Controlled: value is the
concatenated code. Handles focus advance, Backspace retreat, arrow keys, and
paste distribution; the first box carries autocomplete="one-time-code".
const [code, setCode] = useState("")
<CodeInput value={code} onChange={setCode} autoFocus />
// Error state — pairs with an error toast above the CTA
<CodeInput value={code} onChange={setCode} error />
// 4-digit PIN
<CodeInput value={code} onChange={setCode} length={4} />WheelDatePicker
iOS-style month/day/year wheel picker in a centered overlay. Future dates are
disabled; days clamp when the month changes. dark (frosted glass, default)
or light panel.
<WheelDatePicker
open={open}
value={date}
onCancel={() => setOpen(false)}
onConfirm={(d) => { setDate(d); setOpen(false) }}
/>
// Light panel, custom year range
<WheelDatePicker open={open} theme="light" minYear={2020} value={date} onCancel={close} onConfirm={apply} />FilterBar
import { FilterBar, FilterBarItem, FilterBarStar } from 'qu-notify-design-system/components'
import { Store, Calendar } from 'qu-notify-design-system/icons'
<FilterBar trailing={<FilterBarStar active onClick={openSavedViews} />}>
<FilterBarItem icon={<Store size={16} />} label="Main St" onClick={openStorePicker} />
<FilterBarItem icon={<Calendar size={16} />} label="Last 7 days" onClick={openDatePicker} />
</FilterBar>DataTable
Applies a default horizontal screen gutter (px-4) so the card doesn't touch viewport edges. Use flush when the parent already provides inset (drawers, modals, padded sections).
<DataTable
boldHeaders
rows={rows}
getRowKey={(r) => r.id}
columns={[
{ key: "store", header: "Store", ellipsize: true },
{
key: "laborPct",
header: "Labor / Net Sales %",
money: true,
drillDown: true,
sortDirection: "desc",
onSort: toggleSort,
onCellClick: (row) => drillInto(row),
},
]}
/>
{/* Inside an already-padded container */}
<DataTable flush rows={rows} getRowKey={(r) => r.id} columns={columns} />CompareTable
<CompareTable
columns={[{ key: "sales", header: "Net Sales", align: "right" }]}
rows={[
{
id: "1",
label: "Breakfast",
primary: { sales: "$4,200" },
previous: { sales: "$3,900" },
},
]}
/>Charts (qu-notify-design-system/charts)
Charts ship under a dedicated export path so screens can lazy-load Recharts-heavy views without pulling chart code into the default components bundle.
import {
CHART_COLORS,
CategoryPieChart,
CompareChartLegend,
CompareLineChart,
DualAxisLineChart,
TrendLineChart,
} from "qu-notify-design-system/charts"
<CompareChartLegend primaryLabel="Today" comparisonLabel="Previous Day" />
<CompareLineChart
data={rows}
xKey="hour"
primaryKey="today"
comparisonKey="previous"
primaryLabel="Today"
comparisonLabel="Previous Day"
/>
<CategoryPieChart
size={240}
data={[
{ name: "Credit Card", value: 62.4 },
{ name: "Cash", value: 24.1 },
{ name: "Gift Card", value: 13.5 },
]}
/>
<DualAxisLineChart
data={rows}
xKey="hour"
leftKey="avgSec"
rightKey="orders"
leftLabel="SOS"
rightLabel="Orders"
xAxisType="number"
xDomain={[0, 24]}
/>
<TrendLineChart
data={[
{ label: "May 31", value: 64 },
{ label: "Jun 1", value: 68 },
{ label: "Jun 2", value: 69 },
{ label: "Jun 3", value: 73 },
{ label: "Jun 4", value: 74 },
{ label: "Jun 5", value: 74 },
{ label: "Jun 6", value: 82 },
]}
variant="dark"
/>
// Primary chart color is the accessible teal token (#339FB8)
CHART_COLORS.primaryTrendTag
Compact trend badge for dense table cells (distinct from tile-level TrendBadge):
<TrendTag value={11.8} /> // success + up arrow
<TrendTag value={-5.6} compact /> // smaller size for tight columnsScreenshots
27 production screenshots in screenshots/. Key ones for design reference:
| File | Shows |
|---|---|
| sales-dashboard.png | Full dashboard, metric tile grid, context bar, tab bar, bottom nav |
| sign-in.png | Auth screen, input field states, primary button, "Powered by" footer |
| menu-overlay.png | Bottom sheet, dark scrim, menu structure, "NEW" badge |
| net-sales.png | Detail screen, line chart, selected table row, back navigation |
| check-search.png | Check cards, teal check numbers, section headers |
| dashboard-store-kitchen.png | Nested tab bars, data table, teal action link |
| open-tills.png | Empty state pattern, 3-tab bar |
| enable-face-id.png | Modal overlay pattern |
| loading.png | Splash screen, brand illustration treatment |
| dashboard-loading-error-with-toast.png | Error state, toast pattern |
Voice & copy
- Direct and factual. No marketing copy in UI. "Net Sales" not "Your earnings today."
- Metric-first. Lead with the number; context follows.
- Sentence case for all UI copy. ALL CAPS only for
NOTIFYwordmark and section category labels (KITCHEN,NETWORK). - No emoji. Ever.
- Present tense for states: "Sign in to continue", "No Tills Found", "Something Went Wrong".
- Button labels are definitive: "Sign In", "Refresh", "Enable Face ID" — no ellipsis, no "ing" forms.
- Error toast copy (production): "Ooops, we are having problems"
Known gaps
- Kitchen Intelligence composite screen components (72px score display, 34px day-part switcher) — KI palette tokens,
SheetSelect, andWheelDatePickercover the picker/date surfaces; dedicated score/switcher components remain - Advanced chart variants not yet extracted (ring KPI donut / DualRingDonut)
- Code Connect mappings —
.figma.tsxstubs for v1.8.0+ components await real Figma node IDs (seeCHANGELOG.md)
