mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
# Design System Unification — one visual language, enforced end-to-end
Establishes **one source of truth for every visual decision**, wires the
whole app to it, and **locks it in with CI** so it can't drift back.
### Foundations
- **Design-token system ("one knob per decision").** Every colour, text
size, spacing, corner radius, and shadow is a named token defined in
exactly one place — change the token, every screen updates. Collapsed
hundreds of near-duplicate definitions and removed "fake" duplicates
that pretended to be different but weren't.
- **Shared component library (O2).** Buttons, tables, dialogs, drawers,
form fields, badges, etc. already know the correct colours, spacing, and
dark-mode behaviour; new screens compose them instead of hand-styling.
Added new primitives on this branch (`OContent`, `KpiCard`) and
standardized existing ones.
- **One page structure everywhere** (`PageLayout` / `AppPageHeader` /
`AppPage` / `OContent`) — titles, breadcrumbs, spacing, and content
edges line up identically across every screen.
- **One table implementation** — all tabular data uses the same table
(sorting, density, sticky headers, empty states).
- **Styling consolidated onto Tailwind; old systems removed** —
hand-written CSS driven to the practical minimum, the legacy **Quasar**
framework fully removed, surviving stylesheets required to justify
themselves.
- **Two-tier corner radius** — a tight **4px** for controls and a soft
**12px** for surfaces (dialogs, cards, the app shell), plus a
full-circle option — replacing five radius names that all secretly meant
the same thing.
### Detailed improvements across screens
**Page structure & alignment** — unified page shell rolled across ~40+
screens (IAM, Settings, Alerts, Dashboards, Functions, Pipelines, RUM,
Metrics, Synthetics, Billing, editors, lists); one header height; one
page-edge grid driving headers, tables, rails, and tabs; unified
side-rail widths; inset rail selection/hover pills; every tab strip on
the grid line; tightened title/subtitle spacing.
**Tables (cross-screen)** — action columns consistently centered (incl.
Organizations); unified row-number column with no "#" truncation;
non-standard tables restyled to match; loading skeletons made
geometrically identical to the loaded table; symmetric edge inset; IAM
first-column grid alignment fixed.
**Traces** — flame graph + span-detail correlation panel fixes;
correlated logs/metrics placeholder fit; sticky headers; removed a
double scrollbar + a 1px sliver; aligned table fonts/badges; polished
the service detail drawer; stream-selector + correlation alignment;
aligned loading spinners; floating overlay arrows for the
disabled-services strip.
**Logs** — field-list controls aligned (focus ring no longer clipped);
detail-drawer spacing + metrics-tab overflow fixed; Run Query toolbar
tidied; listing sizing + stray border fixed.
**Metrics** — search added to the "Type" facet rail and aligned with the
other rails; proper empty states for the explorer and facet rail.
**Dashboards** — removed inconsistent card shadows; tightened
borders/resizers/field-list spacing; aligned View Dashboard chrome to
the grid; chart colours via the token helper.
**Home** — redesigned the Usage tab; added reusable KPI stat cards so
summary strips stop drifting.
**Dark mode** — new theme-switch animation; fixed a 1px content shift on
toggle and a white flash on load; fixed AI-chat text and assorted
light/dark mismatches; completed the token-driven dark migration.
**Empty states, controls & small polish** — restored 3 broken
empty-state animations; adopted the standard empty state; added the
sliding indicator to the toggle control; fixed the Quality-page inset +
drawer badge; refreshed Search History / Schedulers; fixed the
alert-history drawer scrollbar + timeline hover; removed a stray
menu-bar border.
### Dead code & cruft removal
- **Deleted the entire legacy `utilities.css`** global stylesheet (well
over a thousand lines) — evacuated in waves, roughly half of it dead
once traced.
- **Removed Quasar entirely**, including dead `.q-*` styles and ~25
phantom `--q-*` variables that were silently breaking bits of the UI.
- Stripped hundreds of **dead CSS classes and tokens that compiled to
nothing** — e.g. 46 uses of a colour class with no backing token, a
no-op shadow class on 51 elements, dead layout/utility classes, a
leftover `card-container` block, a dead table-actions token, and a
mis-registered gradient that rendered nothing.
- Cut **unused keyframe animations (19 → 3)** and removed **unused
components** + a stray build artifact.
- Net effect: a much smaller, leaner styling surface — less to maintain,
fewer dead ends to copy.
## Safety & verification
Large but low-risk: this is overwhelmingly a **styling and structure**
change, not product behaviour or data. Guarded by the
design-token/consistency/type checks above plus the existing test suite,
and reviewed screen-by-screen.
---------
Co-authored-by: Kirtan Thakkar <kirtan@kiara.tech>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: ktx-mihir <mihir.patel@kiara.tech>
222 lines
7.6 KiB
JavaScript
222 lines
7.6 KiB
JavaScript
#!/usr/bin/env node
|
|
// Copyright 2026 OpenObserve Inc.
|
|
//
|
|
// CI guard: the 4 token files must contain TOKENS ONLY — no class/element rules,
|
|
// no keyframes, no media queries, no layers. See O2_STYLE_MIGRATION_PLAN.md §5.1.
|
|
//
|
|
// Sanctioned constructs (W1 target end-state):
|
|
// :root { --x: … } token definitions (color-scheme allowed)
|
|
// .dark / :root.dark / .dark :root dark token overrides (--x only)
|
|
// [data-variant=…] / [data-x=…] variant token overrides (--x only)
|
|
// @theme { … } / @theme inline Tailwind registration
|
|
// @font-face { … } font declarations (base.css)
|
|
// comments
|
|
//
|
|
// Anything else is non-token content that must be evacuated to base-elements.css,
|
|
// utilities.css, or a component. Starts in ratchet mode (warn vs baseline) and
|
|
// flips to error in W1.e by deleting token-purity-baseline.json.
|
|
//
|
|
// Run:
|
|
// node scripts/check-token-purity.mjs # fail if worse than baseline
|
|
// node scripts/check-token-purity.mjs --baseline # (re)write the baseline
|
|
// node scripts/check-token-purity.mjs --list # show every offending construct
|
|
|
|
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const TOKENS_DIR = join(__dirname, "..", "src", "lib", "styles", "tokens");
|
|
const BASELINE = join(__dirname, "token-purity-baseline.json");
|
|
const FILES = ["base.css", "semantic.css", "component.css", "dark.css"];
|
|
|
|
// Selectors that may legitimately carry token definitions.
|
|
// `[data-…]` uses `[^\]]*` (not `[a-z-]+…[^\]]*`) so a single selector has one
|
|
// unambiguous parse — the overlapping-quantifier form tripped CodeQL's ReDoS check.
|
|
const TOKEN_SELECTOR =
|
|
"(?::root|html|\\.dark|:root\\.dark|\\.dark :root|\\[data-[^\\]]*\\]|\\.dark \\[data-[^\\]]*\\])";
|
|
const TOKEN_SELECTOR_RE = new RegExp(
|
|
`^${TOKEN_SELECTOR}(?:\\s*,\\s*${TOKEN_SELECTOR})*$`,
|
|
);
|
|
|
|
// Declarations allowed alongside custom properties inside a token block.
|
|
const ALLOWED_DECL_RE = /^(color-scheme|--[A-Za-z0-9_-]+)$/;
|
|
|
|
/**
|
|
* Strip comments while preserving byte offsets (so line numbers stay correct).
|
|
*/
|
|
function stripComments(css) {
|
|
return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
|
|
}
|
|
|
|
/**
|
|
* Walk top-level constructs of a stylesheet.
|
|
* Returns [{ kind: 'at'|'rule', prelude, body, line }]
|
|
*/
|
|
function topLevelConstructs(css) {
|
|
const out = [];
|
|
let depth = 0;
|
|
let start = 0;
|
|
let braceStart = -1;
|
|
|
|
for (let i = 0; i < css.length; i++) {
|
|
const ch = css[i];
|
|
if (ch === "{") {
|
|
if (depth === 0) braceStart = i;
|
|
depth++;
|
|
} else if (ch === "}") {
|
|
depth--;
|
|
if (depth === 0) {
|
|
const prelude = css.slice(start, braceStart).trim();
|
|
const body = css.slice(braceStart + 1, i);
|
|
const line = css.slice(0, start).split("\n").length;
|
|
if (prelude) {
|
|
out.push({
|
|
kind: prelude.startsWith("@") ? "at" : "rule",
|
|
prelude,
|
|
body,
|
|
line,
|
|
});
|
|
}
|
|
start = i + 1;
|
|
}
|
|
} else if (ch === ";" && depth === 0) {
|
|
// top-level statement, e.g. @import / @charset
|
|
const stmt = css.slice(start, i).trim();
|
|
if (stmt.startsWith("@")) {
|
|
out.push({
|
|
kind: "at",
|
|
prelude: stmt,
|
|
body: "",
|
|
line: css.slice(0, start).split("\n").length,
|
|
});
|
|
}
|
|
start = i + 1;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Property names declared directly in a block body (ignores nested blocks).
|
|
*/
|
|
function declaredProps(body) {
|
|
const flat = body.replace(/\{[^{}]*\}/g, "");
|
|
const props = [];
|
|
for (const seg of flat.split(";")) {
|
|
const idx = seg.indexOf(":");
|
|
if (idx === -1) continue;
|
|
const name = seg.slice(0, idx).trim();
|
|
if (name) props.push(name);
|
|
}
|
|
return props;
|
|
}
|
|
|
|
const violations = [];
|
|
|
|
for (const file of FILES) {
|
|
const full = join(TOKENS_DIR, file);
|
|
if (!existsSync(full)) continue;
|
|
const css = stripComments(readFileSync(full, "utf8"));
|
|
|
|
for (const node of topLevelConstructs(css)) {
|
|
const at = node.prelude.split(/\s|\{/)[0].toLowerCase();
|
|
|
|
if (node.kind === "at") {
|
|
// @theme / @theme inline / @font-face are sanctioned.
|
|
if (at === "@theme" || at === "@font-face" || at === "@import" || at === "@charset") continue;
|
|
violations.push({
|
|
file,
|
|
line: node.line,
|
|
selector: node.prelude.split("\n")[0].slice(0, 80),
|
|
reason: `${at} is not a token construct`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Plain rule: selector must be a token-carrying selector...
|
|
const selector = node.prelude.replace(/\s+/g, " ").trim();
|
|
if (!TOKEN_SELECTOR_RE.test(selector)) {
|
|
violations.push({
|
|
file,
|
|
line: node.line,
|
|
selector: selector.slice(0, 80),
|
|
reason: "class/element selector — not a token block",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// ...and may only declare custom properties.
|
|
const bad = declaredProps(node.body).filter((p) => !ALLOWED_DECL_RE.test(p));
|
|
if (bad.length > 0) {
|
|
violations.push({
|
|
file,
|
|
line: node.line,
|
|
selector: selector.slice(0, 80),
|
|
reason: `non-token declaration(s): ${[...new Set(bad)].slice(0, 4).join(", ")}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Count per file.
|
|
const current = {};
|
|
for (const v of violations) current[v.file] = (current[v.file] || 0) + 1;
|
|
const total = violations.length;
|
|
|
|
if (process.argv.includes("--list")) {
|
|
for (const v of violations) {
|
|
console.log(` ${v.file}:${v.line} ${v.selector}\n → ${v.reason}`);
|
|
}
|
|
console.log(`\n${total} non-token construct(s).`);
|
|
process.exit(0);
|
|
}
|
|
|
|
if (process.argv.includes("--baseline")) {
|
|
writeFileSync(BASELINE, JSON.stringify({ total, files: current }, null, 2) + "\n");
|
|
console.log(`Wrote token-purity baseline: ${total} non-token construct(s) across ${Object.keys(current).length} file(s).`);
|
|
process.exit(0);
|
|
}
|
|
|
|
// No baseline = zero-tolerance mode (the W1.e end state).
|
|
if (!existsSync(BASELINE)) {
|
|
if (total === 0) {
|
|
console.log("OK — token files contain tokens only.");
|
|
process.exit(0);
|
|
}
|
|
console.error(`\ncheck-token-purity: ${total} non-token construct(s) in token files (zero-tolerance mode):\n`);
|
|
for (const v of violations.slice(0, 40)) {
|
|
console.error(` ${v.file}:${v.line} ${v.selector}\n → ${v.reason}`);
|
|
}
|
|
if (total > 40) console.error(` ...and ${total - 40} more (--list to see all)`);
|
|
console.error("\nToken files may contain tokens only. Move rules to src/styles/base-elements.css,");
|
|
console.error("src/styles/utilities.css, or the owning component. See O2_STYLE_MIGRATION_PLAN.md §3.\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
const baseline = JSON.parse(readFileSync(BASELINE, "utf8"));
|
|
const base = baseline.files || {};
|
|
const regressions = [];
|
|
let improved = 0;
|
|
|
|
for (const file of new Set([...Object.keys(current), ...Object.keys(base)])) {
|
|
const now = current[file] || 0;
|
|
const was = base[file] || 0;
|
|
if (now > was) regressions.push({ file, was, now });
|
|
else if (now < was) improved += was - now;
|
|
}
|
|
|
|
if (regressions.length > 0) {
|
|
console.error(`\ncheck-token-purity: ${regressions.length} regression(s) above baseline:\n`);
|
|
for (const r of regressions) {
|
|
console.error(` ${r.file} ${r.was} → ${r.now} (+${r.now - r.was})`);
|
|
}
|
|
console.error("\nDo not add class/element/keyframe rules to token files. See O2_STYLE_MIGRATION_PLAN.md §3.\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`check-token-purity: OK — ${total} non-token construct(s), at or below baseline (${baseline.total}).`);
|
|
if (improved > 0) {
|
|
console.log(`Improved by ${improved} — re-run with --baseline and commit the update.`);
|
|
}
|